Updated apps

This commit is contained in:
2026-07-20 22:52:35 -04:00
parent 28a8cb98f6
commit a0c3271743
1164 changed files with 94781 additions and 6892 deletions
+120
View File
@@ -0,0 +1,120 @@
"""Spook - Your homie. Custom integration for Home Assistant."""
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING
from homeassistant.const import (
EVENT_HOMEASSISTANT_START,
EVENT_HOMEASSISTANT_STARTED,
RESTART_EXIT_CODE,
)
from homeassistant.core import (
CoreState,
callback,
)
from homeassistant.helpers import issue_registry as ir
from .const import DOMAIN, LOGGER, PLATFORMS
from .entity_filtering import async_setup_all_entity_ids_cache_invalidation
from .integration_linking import link_sub_integrations, unlink_sub_integrations
from .listeners import async_listen_once_tracked
from .repairs import SpookRepairManager
from .services import SpookServiceManager
from .setup_helpers import async_forward_setup_entry
if TYPE_CHECKING:
from collections.abc import Callable
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import Event, HomeAssistant
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up from a config entry."""
# Symlink all sub integrations from Spook to the parent integrations folder
# if one is missing, we have to restart Home Assistant.
# This is a workaround for the fact that Home Assistant doesn't support
# sub integrations.
if await hass.async_add_executor_job(link_sub_integrations, hass):
LOGGER.debug("Newly symlinked sub integrations, restarting Home Assistant")
@callback
def _restart(_: Event | None = None) -> None:
"""Restart Home Assistant."""
hass.data["homeassistant_stop"] = asyncio.create_task(
hass.async_stop(RESTART_EXIT_CODE),
)
# User asked to restart Home Assistant in the config flow.
if hass.data.get(DOMAIN) == "Boo!":
_restart()
return False
# Should be OK to restart. Better to do it before anything else started.
if hass.state == CoreState.starting:
_restart()
return False
# If all other fails, but we are not running yet... wait for it.
if hass.state == CoreState.not_running:
# Listen to both... just in case.
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _restart)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _restart)
return False
LOGGER.info(
"Home Assistant needs to be restarted in for Spook to complete setting up",
)
ir.async_create_issue(
hass=hass,
domain=DOMAIN,
issue_id="restart_required",
is_fixable=True,
severity=ir.IssueSeverity.WARNING,
translation_key="restart_required",
)
# Forward async_setup_entry to ectoplasms
await async_forward_setup_entry(hass, entry)
# Set up services
services = SpookServiceManager(hass)
await services.async_setup()
entry.async_on_unload(services.async_on_unload)
# Who you gonna call? SpookRepairManager!
repairs = SpookRepairManager(hass)
_ghost_busters_unsub: Callable[[], None] | None = None
async def _ghost_busters(_: Event) -> None:
"""Send them in, time for some ghost chasing."""
await repairs.async_setup()
entry.async_on_unload(repairs.async_on_unload)
# Wait until Home Assistant is started, before doing repairs
_ghost_busters_unsub = async_listen_once_tracked(
hass, EVENT_HOMEASSISTANT_STARTED, _ghost_busters
)
entry.async_on_unload(_ghost_busters_unsub)
# Set up platforms
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Set up the all entity ids cache invalidation
entry.async_on_unload(async_setup_all_entity_ids_cache_invalidation(hass))
# Yay, we didn't got spooked!
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_remove_entry(hass: HomeAssistant, _: ConfigEntry) -> None:
"""Remove a config entry."""
await hass.async_add_executor_job(unlink_sub_integrations, hass)
+28
View File
@@ -0,0 +1,28 @@
"""Spook - Your homie."""
from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.const import Platform
from .setup_helpers import async_forward_platform_entry_setups_to_ectoplasm
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Spook binary sensors."""
await async_forward_platform_entry_setups_to_ectoplasm(
hass,
entry,
async_add_entities,
Platform.BINARY_SENSOR,
)
+28
View File
@@ -0,0 +1,28 @@
"""Spook - Your homie."""
from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.const import Platform
from .setup_helpers import async_forward_platform_entry_setups_to_ectoplasm
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Spook buttons."""
await async_forward_platform_entry_setups_to_ectoplasm(
hass,
entry,
async_add_entities,
Platform.BUTTON,
)
+115
View File
@@ -0,0 +1,115 @@
"""Spook - Your homie."""
from __future__ import annotations
from typing import Any
import voluptuous as vol
from homeassistant.config_entries import (
ConfigEntry,
ConfigEntryDisabler,
ConfigFlow,
ConfigFlowResult,
)
from .const import DOMAIN
class UptimeConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow for Spook."""
VERSION = 1
_disabled_entry: ConfigEntry | None = None
def _async_get_user_disabled_entry(self) -> ConfigEntry | None:
"""Return an existing user-disabled Spook config entry."""
for entry in self._async_current_entries():
if entry.disabled_by is ConfigEntryDisabler.USER:
return entry
return None
async def async_step_user(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Handle a flow initialized by someone that didn't read the warnings."""
if current_entries := self._async_current_entries():
if any(entry.disabled_by is None for entry in current_entries):
return self.async_abort(reason="already_spooked")
self._disabled_entry = self._async_get_user_disabled_entry()
if self._disabled_entry is not None:
return self.async_show_menu(
step_id="already_configured",
menu_options=["enable_existing"],
)
return self.async_abort(reason="already_spooked")
if user_input is not None:
return await self.async_step_choice_restart()
return self.async_show_form(step_id="user", data_schema=vol.Schema({}))
async def async_step_already_configured(
self,
_: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Handle an already configured disabled Spook entry."""
self._disabled_entry = self._async_get_user_disabled_entry()
if self._disabled_entry is None:
return self.async_abort(reason="already_spooked")
return self.async_show_menu(
step_id="already_configured",
menu_options=["enable_existing"],
)
async def async_step_enable_existing(
self,
_: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Enable the existing disabled Spook config entry."""
self._disabled_entry = self._async_get_user_disabled_entry()
if self._disabled_entry is None:
return self.async_abort(reason="already_spooked")
if await self.hass.config_entries.async_set_disabled_by(
self._disabled_entry.entry_id,
disabled_by=None,
):
return self.async_abort(reason="enabled_existing")
return self.async_abort(reason="enable_failed")
async def async_step_choice_restart(
self,
_: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Handle the user's choice.
Allows the user to choose to restart now or later.
"""
return self.async_show_menu(
step_id="choice_restart",
menu_options=["restart_now", "restart_later"],
)
async def async_step_restart_later(
self,
_: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Handle restart later case."""
return self.async_create_entry(title="Your homie", data={})
async def async_step_restart_now(
self,
_: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Handle restart now case.
Sets a flag, so the integraton setup knows it can go ahead and restart.
"""
self.hass.data[DOMAIN] = "Boo!"
return await self.async_step_restart_later()
+20
View File
@@ -0,0 +1,20 @@
"""Spook - Your homie."""
import logging
from typing import Final
from homeassistant.const import Platform
DOMAIN: Final = "spook"
LOGGER = logging.getLogger(__package__)
PLATFORMS: Final = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.EVENT,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.TIME,
]
@@ -0,0 +1 @@
"""Spook - Your homie."""
@@ -0,0 +1 @@
"""Spook - Your homie."""
@@ -0,0 +1 @@
"""Spook - Your homie."""
@@ -0,0 +1,45 @@
"""Spook - Your homie."""
from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.components import automation
from homeassistant.helpers import area_registry as ar
from ....entity_filtering import async_filter_known_area_ids, async_get_all_area_ids
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
if TYPE_CHECKING:
from typing import Any
class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
"""Spook repair tries to find unknown referenced areas in automations."""
domain = automation.DOMAIN
repair = "automation_unknown_area_references"
inspect_events = {
automation.EVENT_AUTOMATION_RELOADED,
ar.EVENT_AREA_REGISTRY_UPDATED,
}
inspect_on_reload = True
unavailable_entity_class = automation.UnavailableAutomationEntity
entity_label = "automation"
reference_label = "areas"
edit_url_pattern = "/config/automation/edit/{unique_id}"
_known_area_ids: set[str]
async def _async_setup_inspection(self) -> None:
"""Cache known area IDs for this inspection cycle."""
self._known_area_ids = async_get_all_area_ids(self.hass)
async def _async_compute_unknown_references(self, entity: Any) -> set[str]:
"""Return unknown area IDs referenced by ``entity``."""
return async_filter_known_area_ids(
self.hass,
area_ids=entity.referenced_areas,
known_area_ids=self._known_area_ids,
)
@@ -0,0 +1,90 @@
"""Spook - Your homie."""
from __future__ import annotations
from typing import Any
from homeassistant.components import automation
from homeassistant.helpers import device_registry as dr
from ....entity_filtering import async_filter_known_device_ids, async_get_all_device_ids
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
def extract_event_data_device_ids_from_trigger_config(
config: dict[str, Any] | list,
) -> set[str]:
"""Extract device IDs from event trigger data."""
device_ids = set()
if not config:
return device_ids
if isinstance(config, list):
for item in config:
device_ids.update(extract_event_data_device_ids_from_trigger_config(item))
return device_ids
if not isinstance(config, dict):
return device_ids
if config.get("platform", config.get("trigger")) == "event" and isinstance(
event_data := config.get("event_data"), dict
):
value = event_data.get("device_id")
if isinstance(value, str):
device_ids.add(value)
elif isinstance(value, list):
device_ids.update(item for item in value if isinstance(item, str))
for value in config.values():
if isinstance(value, (dict, list)):
device_ids.update(extract_event_data_device_ids_from_trigger_config(value))
return device_ids
class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
"""Spook repair tries to find unknown referenced devices in automations."""
domain = automation.DOMAIN
repair = "automation_unknown_device_references"
inspect_events = {
automation.EVENT_AUTOMATION_RELOADED,
dr.EVENT_DEVICE_REGISTRY_UPDATED,
}
inspect_config_entry_changed = True
inspect_on_reload = True
unavailable_entity_class = automation.UnavailableAutomationEntity
entity_label = "automation"
reference_label = "devices"
edit_url_pattern = "/config/automation/edit/{unique_id}"
_known_device_ids: set[str]
async def _async_setup_inspection(self) -> None:
"""Cache known device IDs for this inspection cycle."""
self._known_device_ids = async_get_all_device_ids(self.hass)
async def _async_compute_unknown_references(self, entity: Any) -> set[str]:
"""Return unknown device IDs referenced by ``entity``."""
device_ids = set(entity.referenced_devices)
if hasattr(entity, "raw_config") and entity.raw_config:
device_ids.difference_update(
extract_event_data_device_ids_from_trigger_config(
entity.raw_config.get("trigger")
)
)
device_ids.difference_update(
extract_event_data_device_ids_from_trigger_config(
entity.raw_config.get("triggers")
)
)
return async_filter_known_device_ids(
self.hass,
device_ids=device_ids,
known_device_ids=self._known_device_ids,
)
@@ -0,0 +1,391 @@
"""Spook - Your homie."""
from __future__ import annotations
import re
from typing import TYPE_CHECKING, Any
from homeassistant.components import automation
from homeassistant.const import EVENT_COMPONENT_LOADED, EVENT_STATE_CHANGED
from homeassistant.core import Event, callback
from homeassistant.helpers import entity_registry as er
from ....const import LOGGER
from ....entity_filtering import (
ENTITY_ID_PATTERN,
async_extract_entities_from_config,
async_extract_entities_from_template_string,
async_filter_known_entity_ids_with_templates,
async_get_all_entity_ids,
is_template_string,
)
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
if TYPE_CHECKING:
from collections.abc import Mapping
from homeassistant.core import HomeAssistant
async def extract_template_entities_from_automation_entity(
hass: HomeAssistant, entity: Any
) -> set[str]:
"""Extract entities from automation configuration using Template analysis.
This function finds template strings in automation configuration and creates
Template objects to extract entity references using Template.async_render_to_info().
This provides more comprehensive entity detection than regex-based parsing alone.
"""
# Get the automation configuration
config = None
if hasattr(entity, "raw_config") and entity.raw_config:
config = entity.raw_config
else:
return set()
return await async_extract_entities_from_config(hass, config)
async def extract_entities_from_automation_config(
hass: HomeAssistant, config: dict[str, Any]
) -> set[str]:
"""Extract entity IDs from automation configuration."""
entities = set()
if not isinstance(config, dict):
return entities
# Extract entities from trigger config
for key in ("trigger", "triggers"):
if key in config:
entities.update(
await extract_entities_from_trigger_config(hass, config[key])
)
# Extract entities from condition config
for key in ("condition", "conditions"):
if key in config:
entities.update(
await extract_entities_from_condition_config(hass, config[key])
)
# Extract entities from action config
for key in ("action", "actions"):
if key in config:
entities.update(
await extract_entities_from_action_config(hass, config[key])
)
return entities
async def extract_entities_from_trigger_config(
hass: HomeAssistant, config: dict[str, Any] | list
) -> set[str]:
"""Extract entity IDs from trigger configuration."""
entities = set()
if not config:
return entities
if isinstance(config, list):
for item in config:
entities.update(await extract_entities_from_trigger_config(hass, item))
return entities
if not isinstance(config, dict):
return entities
# Entity ID fields in triggers
for key in ("entity_id", "device_id"):
if key in config:
entities.update(await extract_entities_from_value(hass, config[key]))
# Zone trigger has zone field
if "zone" in config:
entities.update(await extract_entities_from_value(hass, config["zone"]))
# Extract from nested configs
for value in config.values():
if isinstance(value, (dict, list)):
entities.update(await extract_entities_from_trigger_config(hass, value))
return entities
def extract_event_types_from_trigger_config(config: dict[str, Any] | list) -> set[str]:
"""Extract event types from trigger configuration."""
event_types = set()
if not config:
return event_types
if isinstance(config, list):
for item in config:
event_types.update(extract_event_types_from_trigger_config(item))
return event_types
if not isinstance(config, dict):
return event_types
value = config.get("event_type")
if isinstance(value, str):
event_types.add(value)
elif isinstance(value, list):
event_types.update(item for item in value if isinstance(item, str))
for value in config.values():
if isinstance(value, (dict, list)):
event_types.update(extract_event_types_from_trigger_config(value))
return event_types
async def extract_entities_from_condition_config(
hass: HomeAssistant, config: dict[str, Any] | list
) -> set[str]:
"""Extract entity IDs from condition configuration."""
entities = set()
if not config:
return entities
if isinstance(config, list):
for item in config:
entities.update(await extract_entities_from_condition_config(hass, item))
return entities
if not isinstance(config, dict):
return entities
# Entity ID fields in conditions
for key in ("entity_id", "device_id", "zone"):
if key in config:
entities.update(await extract_entities_from_value(hass, config[key]))
# Extract from nested configs
for value in config.values():
if isinstance(value, (dict, list)):
entities.update(await extract_entities_from_condition_config(hass, value))
return entities
async def extract_entities_from_action_config(
hass: HomeAssistant, config: dict[str, Any] | list
) -> set[str]:
"""Extract entity IDs from action configuration."""
entities = set()
if not config:
return entities
if isinstance(config, list):
for item in config:
entities.update(await extract_entities_from_action_config(hass, item))
return entities
if not isinstance(config, dict):
return entities
# Extract entity IDs from direct fields
entities.update(await _extract_entities_from_action_fields(hass, config))
# Extract entities from target configuration
entities.update(await _extract_entities_from_target(hass, config))
# Extract entities from service data
entities.update(await _extract_entities_from_service_data(hass, config))
# Extract from nested configs (like if/then/else, repeat, etc.)
entities.update(await _extract_entities_from_nested_configs(hass, config))
return entities
async def _extract_entities_from_action_fields(
hass: HomeAssistant, config: dict[str, Any]
) -> set[str]:
"""Extract entities from direct action fields."""
entities = set()
for key in ("entity_id", "device_id"):
if key in config:
entities.update(await extract_entities_from_value(hass, config[key]))
return entities
async def _extract_entities_from_target(
hass: HomeAssistant, config: dict[str, Any]
) -> set[str]:
"""Extract entities from target configuration."""
entities = set()
if "target" in config and isinstance(config["target"], dict):
target = config["target"]
for key in ("entity_id", "device_id", "area_id", "label_id"):
if key in target:
entities.update(await extract_entities_from_value(hass, target[key]))
return entities
def _get_action_service(config: dict[str, Any]) -> str | None:
"""Return the service/action name configured for an action."""
service = config.get("service", config.get("action"))
return service if isinstance(service, str) else None
def _should_skip_service_data_value(
service: str | None,
key: str,
) -> bool:
"""Return if a service data value should not be scanned for entity IDs."""
return service is not None and service.startswith("notify.") and key == "target"
async def _extract_entities_from_service_data(
hass: HomeAssistant, config: dict[str, Any]
) -> set[str]:
"""Extract entities from service data."""
entities = set()
if "data" in config:
data_value = config["data"]
if isinstance(data_value, str):
# data field is a template string itself
entities.update(await extract_entities_from_value(hass, data_value))
elif isinstance(data_value, dict):
service = _get_action_service(config)
# data field is a dictionary, process all its values
for key, value in data_value.items():
if _should_skip_service_data_value(service, key):
continue
entities.update(await extract_entities_from_value(hass, value))
return entities
async def _extract_entities_from_nested_configs(
hass: HomeAssistant, config: dict[str, Any]
) -> set[str]:
"""Extract entities from nested configurations."""
entities = set()
for value in config.values():
if isinstance(value, (dict, list)):
entities.update(await extract_entities_from_action_config(hass, value))
return entities
async def extract_entities_from_value(hass: HomeAssistant, value: Any) -> set[str]:
"""Extract entity IDs from a configuration value."""
entities = set()
if isinstance(value, str):
# Check if it's a template string using util.is_template_string
if is_template_string(value):
# Process as template to extract entity references
try:
template_entities = await async_extract_entities_from_template_string(
hass, value
)
entities.update(template_entities)
# pylint: disable-next=broad-exception-caught
except Exception as exc: # noqa: BLE001 - Keep broad for unexpected template issues
LOGGER.debug(
"Failed to extract entities from template: %s, error: %s",
value,
exc,
)
elif re.match(rf"^{ENTITY_ID_PATTERN}$", value):
# Check if it matches the entity ID pattern with known domains
entities.add(value)
elif isinstance(value, list):
for item in value:
entities.update(await extract_entities_from_value(hass, item))
elif (
isinstance(value, dict)
and "entity" in value
and isinstance(value["entity"], str)
):
# Handle entity dict format like {"entity": "light.living_room"}
entities.add(value["entity"])
return entities
class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
"""Spook repair tries to find unknown referenced entity in automations."""
domain = automation.DOMAIN
repair = "automation_unknown_entity_references"
inspect_events = {
EVENT_COMPONENT_LOADED,
er.EVENT_ENTITY_REGISTRY_UPDATED,
}
inspect_config_entry_changed = True
inspect_on_reload = True
unavailable_entity_class = automation.UnavailableAutomationEntity
entity_label = "automation"
reference_label = "entities"
edit_url_pattern = "/config/automation/edit/{unique_id}"
_known_entity_ids: set[str]
async def async_activate(self) -> None:
"""Activate the repair."""
await super().async_activate()
@callback
def _state_entity_changed(event_data: Mapping[str, Any]) -> bool:
"""Return if a state entity was added or removed."""
return (
event_data.get("old_state") is None
or event_data.get("new_state") is None
)
@callback
def _async_call_inspect_debouncer(_: Event) -> None:
"""Trigger an inspection when a state entity is added or removed."""
self.inspect_debouncer.async_schedule_call()
self._event_subs.add(
self.hass.bus.async_listen(
EVENT_STATE_CHANGED,
_async_call_inspect_debouncer,
event_filter=_state_entity_changed,
),
)
async def _async_setup_inspection(self) -> None:
"""Cache known entity IDs (including ALL/NONE) for this inspection cycle."""
self._known_entity_ids = async_get_all_entity_ids(
self.hass, include_all_none=True
)
def _should_inspect_entity(self, entity: Any) -> bool:
"""Skip disabled automations."""
return entity.enabled
async def _async_compute_unknown_references(self, entity: Any) -> set[str]:
"""Return unknown entity IDs referenced by ``entity`` (incl. templates)."""
all_entities = set(entity.referenced_entities)
# Also extract entities directly from raw configuration if available
if hasattr(entity, "raw_config") and entity.raw_config:
all_entities.update(
await extract_entities_from_automation_config(
self.hass, entity.raw_config
)
)
for key in ("trigger", "triggers"):
all_entities.difference_update(
extract_event_types_from_trigger_config(entity.raw_config.get(key))
)
# Extract entities from Template objects within the automation entity
all_entities.update(
await extract_template_entities_from_automation_entity(self.hass, entity)
)
return await async_filter_known_entity_ids_with_templates(
self.hass,
entity_ids=all_entities,
known_entity_ids=self._known_entity_ids,
)
@@ -0,0 +1,44 @@
"""Spook - Your homie."""
from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.components import automation
from homeassistant.helpers import floor_registry as fr
from ....entity_filtering import async_filter_known_floor_ids, async_get_all_floor_ids
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
if TYPE_CHECKING:
from typing import Any
class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
"""Spook repair tries to find unknown referenced floors in automations."""
domain = automation.DOMAIN
repair = "automation_unknown_floor_references"
inspect_events = {
fr.EVENT_FLOOR_REGISTRY_UPDATED,
}
inspect_on_reload = True
unavailable_entity_class = automation.UnavailableAutomationEntity
entity_label = "automation"
reference_label = "floors"
edit_url_pattern = "/config/automation/edit/{unique_id}"
_known_floor_ids: set[str]
async def _async_setup_inspection(self) -> None:
"""Cache known floor IDs for this inspection cycle."""
self._known_floor_ids = async_get_all_floor_ids(self.hass)
async def _async_compute_unknown_references(self, entity: Any) -> set[str]:
"""Return unknown floor IDs referenced by ``entity``."""
return async_filter_known_floor_ids(
self.hass,
floor_ids=entity.referenced_floors,
known_floor_ids=self._known_floor_ids,
)
@@ -0,0 +1,44 @@
"""Spook - Your homie."""
from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.components import automation
from homeassistant.helpers import label_registry as lr
from ....entity_filtering import async_filter_known_label_ids, async_get_all_label_ids
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
if TYPE_CHECKING:
from typing import Any
class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
"""Spook repair tries to find unknown referenced labels in automations."""
domain = automation.DOMAIN
repair = "automation_unknown_label_references"
inspect_events = {
lr.EVENT_LABEL_REGISTRY_UPDATED,
}
inspect_on_reload = True
unavailable_entity_class = automation.UnavailableAutomationEntity
entity_label = "automation"
reference_label = "labels"
edit_url_pattern = "/config/automation/edit/{unique_id}"
_known_label_ids: set[str]
async def _async_setup_inspection(self) -> None:
"""Cache known label IDs for this inspection cycle."""
self._known_label_ids = async_get_all_label_ids(self.hass)
async def _async_compute_unknown_references(self, entity: Any) -> set[str]:
"""Return unknown label IDs referenced by ``entity``."""
return async_filter_known_label_ids(
self.hass,
label_ids=entity.referenced_labels,
known_label_ids=self._known_label_ids,
)
@@ -0,0 +1,60 @@
"""Spook - Your homie."""
from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.components import automation
from homeassistant.const import (
EVENT_COMPONENT_LOADED,
EVENT_SERVICE_REGISTERED,
EVENT_SERVICE_REMOVED,
)
from ....entity_filtering import (
async_filter_known_services,
async_find_services_in_sequence,
async_get_all_services,
)
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
if TYPE_CHECKING:
from typing import Any
class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
"""Spook repair tries to find unknown referenced services in automations."""
domain = automation.DOMAIN
repair = "automation_unknown_service_references"
inspect_events = {
automation.EVENT_AUTOMATION_RELOADED,
EVENT_COMPONENT_LOADED,
EVENT_SERVICE_REGISTERED,
EVENT_SERVICE_REMOVED,
}
inspect_config_entry_changed = True
inspect_on_reload = True
unavailable_entity_class = automation.UnavailableAutomationEntity
entity_label = "automation"
reference_label = "services"
edit_url_pattern = "/config/automation/edit/{unique_id}"
_known_services: set[str]
async def _async_setup_inspection(self) -> None:
"""Cache known services for this inspection cycle."""
self._known_services = async_get_all_services(self.hass)
def _should_inspect_entity(self, entity: Any) -> bool:
"""Skip disabled automations."""
return entity.enabled
async def _async_compute_unknown_references(self, entity: Any) -> set[str]:
"""Return unknown services called by ``entity``."""
return async_filter_known_services(
self.hass,
services=async_find_services_in_sequence(entity.action_script.sequence),
known_services=self._known_services,
)
@@ -0,0 +1 @@
"""Spook - Your homie."""
@@ -0,0 +1 @@
"""Spook - Your homie."""
@@ -0,0 +1,66 @@
"""Spook - Your homie."""
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING
import aiohttp
import voluptuous as vol
from homeassistant.components.blueprint import DOMAIN
from homeassistant.components.blueprint.errors import FileAlreadyExists
from homeassistant.components.blueprint.importer import fetch_blueprint_from_url
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from ....services import AbstractSpookAdminService
if TYPE_CHECKING:
from homeassistant.components.blueprint.models import DomainBlueprints
from homeassistant.core import ServiceCall
class SpookService(AbstractSpookAdminService):
"""Blueprint integration service to import an Blueprint from an URL."""
domain = DOMAIN
service = "import"
schema = {vol.Required("url"): cv.url}
async def async_handle_service(self, call: ServiceCall) -> None:
"""Handle the service call."""
try:
async with asyncio.timeout(10):
imported_blueprint = await fetch_blueprint_from_url(
self.hass,
call.data["url"],
)
except (TimeoutError, aiohttp.ClientError) as err:
msg = "Error fetching blueprint from URL"
raise HomeAssistantError(msg) from err
if imported_blueprint is None:
msg = "This url is not supported"
raise HomeAssistantError(msg)
domain_blueprints: dict[str, DomainBlueprints] = self.hass.data.get(DOMAIN, {})
if imported_blueprint.blueprint.domain not in domain_blueprints:
msg = f"Unsupported domain: {imported_blueprint.blueprint.domain}"
raise HomeAssistantError(msg)
imported_blueprint.blueprint.update_metadata(source_url=call.data["url"])
try:
await domain_blueprints[
imported_blueprint.blueprint.domain
].async_add_blueprint(
imported_blueprint.blueprint,
imported_blueprint.suggested_filename,
)
except FileAlreadyExists as ex:
msg = "File already exists"
raise HomeAssistantError(msg) from ex
except OSError as err:
msg = "Error writing file"
raise HomeAssistantError(msg) from err
@@ -0,0 +1 @@
"""Spook - Your homie."""
@@ -0,0 +1,41 @@
"""Spook - Your homie."""
from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.components.cloud.const import DOMAIN as CLOUD_DOMAIN
from homeassistant.helpers.device_registry import DeviceInfo
from ...const import DOMAIN
from ...entity import SpookEntity, SpookEntityDescription
if TYPE_CHECKING:
from hass_nabucasa import Cloud
from homeassistant.components.cloud.client import CloudClient
class HomeAssistantCloudSpookEntity(SpookEntity):
"""Defines an base Spook entity for Home Assistant Cloud related entities."""
def __init__(
self, cloud: Cloud[CloudClient], description: SpookEntityDescription
) -> None:
"""Initialize the entity."""
super().__init__(description=description)
self._cloud = cloud
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, CLOUD_DOMAIN)},
manufacturer="Nabu Casa Inc.",
name="Home Assistant Cloud",
configuration_url="https://account.nabucasa.com/",
)
self._attr_unique_id = f"{CLOUD_DOMAIN}_{description.key}"
@property
def available(self) -> bool:
"""Return if cloud services are available."""
return (
super().available and self._cloud.is_logged_in and self._cloud.is_connected
)
@@ -0,0 +1,144 @@
"""Spook - Your homie."""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from homeassistant.components.cloud import DOMAIN as CLOUD_DOMAIN
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from ...entity import SpookEntityDescription
from .entity import HomeAssistantCloudSpookEntity
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from hass_nabucasa import Cloud
from homeassistant.components.cloud.client import CloudClient
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@dataclass(frozen=True, kw_only=True)
class HomeAssistantCloudSpookSwitchEntityDescription(
SpookEntityDescription,
SwitchEntityDescription,
):
"""Class describing Spook Home Assistant sensor entities."""
is_on_fn: Callable[[Cloud[CloudClient]], bool | None]
set_fn: Callable[[Cloud[CloudClient], bool], Awaitable[Any]]
SWITCHES: tuple[HomeAssistantCloudSpookSwitchEntityDescription, ...] = (
HomeAssistantCloudSpookSwitchEntityDescription(
key="alexa",
entity_id="switch.cloud_alexa",
name="Alexa",
icon="mdi:account-voice",
entity_category=EntityCategory.CONFIG,
is_on_fn=lambda cloud: cloud.client.prefs.alexa_enabled,
set_fn=lambda cloud, enabled: cloud.client.prefs.async_update(
alexa_enabled=enabled,
),
),
HomeAssistantCloudSpookSwitchEntityDescription(
key="alexa_report_state",
translation_key="cloud_alexa_report_state",
entity_id="switch.cloud_alexa_report_state",
icon="mdi:account-voice",
entity_category=EntityCategory.CONFIG,
is_on_fn=lambda cloud: cloud.client.prefs.alexa_report_state,
set_fn=lambda cloud, enabled: cloud.client.prefs.async_update(
alexa_report_state=enabled,
),
),
HomeAssistantCloudSpookSwitchEntityDescription(
key="google",
entity_id="switch.cloud_google",
name="Google Assistant",
icon="mdi:google-assistant",
entity_category=EntityCategory.CONFIG,
is_on_fn=lambda cloud: cloud.client.prefs.google_enabled,
set_fn=lambda cloud, enabled: cloud.client.prefs.async_update(
google_enabled=enabled,
),
),
HomeAssistantCloudSpookSwitchEntityDescription(
key="google_report_state",
translation_key="cloud_google_report_state",
entity_id="switch.cloud_google_report_state",
icon="mdi:google-assistant",
entity_category=EntityCategory.CONFIG,
is_on_fn=lambda cloud: cloud.client.prefs.google_report_state,
set_fn=lambda cloud, enabled: cloud.client.prefs.async_update(
google_report_state=enabled,
),
),
HomeAssistantCloudSpookSwitchEntityDescription(
key="remote",
translation_key="cloud_remote",
entity_id="switch.cloud_remote",
icon="mdi:remote-desktop",
entity_category=EntityCategory.CONFIG,
is_on_fn=lambda cloud: cloud.client.prefs.remote_enabled,
set_fn=lambda cloud, enabled: cloud.client.prefs.async_update(
remote_enabled=enabled,
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Spook Home Assistant Cloud switches."""
if CLOUD_DOMAIN in hass.config.components:
cloud: Cloud[CloudClient] = hass.data[CLOUD_DOMAIN]
async_add_entities(
HomeAssistantCloudSpookSwitchEntity(cloud, description)
for description in SWITCHES
)
class HomeAssistantCloudSpookSwitchEntity(HomeAssistantCloudSpookEntity, SwitchEntity):
"""Spook switch providig Home Asistant Cloud controls."""
entity_description: HomeAssistantCloudSpookSwitchEntityDescription
async def async_added_to_hass(self) -> None:
"""Register for switch updates."""
async def _update_state(_: Any) -> None:
"""Update state."""
self.async_schedule_update_ha_state()
self.async_on_remove(
self._cloud.client.prefs.async_listen_updates(_update_state),
)
@property
def icon(self) -> str | None:
"""Return the icon."""
if self.entity_description.icon and self.is_on is False:
return self.entity_description.icon
return super().icon
@property
def is_on(self) -> bool | None:
"""Return state of the switch."""
return self.entity_description.is_on_fn(self._cloud)
async def async_turn_on(self, **_kwargs: Any) -> None:
"""Turn the entity on."""
await self.entity_description.set_fn(self._cloud, True) # noqa: FBT003
async def async_turn_off(self, **_kwargs: Any) -> None:
"""Turn the entity off."""
await self.entity_description.set_fn(self._cloud, False) # noqa: FBT003
@@ -0,0 +1 @@
"""Spook - Your homie."""
@@ -0,0 +1 @@
"""Spook - Your homie."""
@@ -0,0 +1,73 @@
"""Spook - Your homie."""
from __future__ import annotations
from homeassistant.components import group
from homeassistant.const import (
EVENT_COMPONENT_LOADED,
)
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import DATA_ENTITY_PLATFORM, EntityPlatform
from ....const import LOGGER
from ....entity_filtering import async_filter_known_entity_ids, async_get_all_entity_ids
from ....repairs import AbstractSpookRepair
class SpookRepair(AbstractSpookRepair):
"""Spook repair tries to find unknown member entities in groups."""
domain = group.DOMAIN
repair = "group_unknown_members"
inspect_events = {
EVENT_COMPONENT_LOADED,
er.EVENT_ENTITY_REGISTRY_UPDATED,
}
inspect_config_entry_changed = group.DOMAIN
inspect_on_reload = True
automatically_clean_up_issues = True
async def async_inspect(self) -> None:
"""Trigger a inspection."""
LOGGER.debug("Spook is inspecting: %s", self.repair)
known_entity_ids = async_get_all_entity_ids(self.hass)
platforms: list[EntityPlatform] | None
if not (platforms := self.hass.data[DATA_ENTITY_PLATFORM].get(self.domain)):
return # Nothing to do.
for platform in platforms:
# We don't want to check the old style group platform
for entity in platform.entities.values():
self.possible_issue_ids.add(entity.entity_id)
members = []
if platform.domain == group.DOMAIN:
members = entity.tracking
elif hasattr(entity, "_entity_ids"):
# pylint: disable-next=protected-access
members = entity._entity_ids # noqa: SLF001
elif hasattr(entity, "_entities"):
# pylint: disable-next=protected-access
members = entity._entities # noqa: SLF001
if unknown_entities := async_filter_known_entity_ids(
self.hass, entity_ids=members, known_entity_ids=known_entity_ids
):
self.async_create_issue(
issue_id=entity.entity_id,
translation_placeholders={
"entities": "\n".join(
f"- `{entity_id}`" for entity_id in unknown_entities
),
"group": entity.name,
"entity_id": entity.entity_id,
},
)
LOGGER.debug(
"Spook found unknown member entities in %s "
"and created an issue for it; Entities: %s",
entity.entity_id,
", ".join(unknown_entities),
)
@@ -0,0 +1 @@
"""Spook - Your homie."""
@@ -0,0 +1,87 @@
"""Spook - Your homie."""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from homeassistant.components.button import (
ButtonDeviceClass,
ButtonEntity,
ButtonEntityDescription,
)
from homeassistant.components.homeassistant import (
DOMAIN,
SERVICE_HOMEASSISTANT_RESTART,
SERVICE_RELOAD_ALL,
)
from homeassistant.const import EntityCategory
from ...entity import SpookEntityDescription
from .entity import HomeAssistantSpookEntity
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@dataclass(frozen=True, kw_only=True)
class HomeAssistantSpookButtonEntityDescription(
SpookEntityDescription,
ButtonEntityDescription,
):
"""Class describing Spook Home Assistant button entities."""
press_fn: Callable[[HomeAssistant], Awaitable[Any]]
BUTTONS: tuple[HomeAssistantSpookButtonEntityDescription, ...] = (
HomeAssistantSpookButtonEntityDescription(
key="restart",
translation_key="homeassistant_restart",
entity_id="button.homeassistant_restart",
device_class=ButtonDeviceClass.RESTART,
entity_category=EntityCategory.CONFIG,
press_fn=lambda hass: hass.services.async_call(
DOMAIN,
SERVICE_HOMEASSISTANT_RESTART,
blocking=True,
),
),
HomeAssistantSpookButtonEntityDescription(
key="reload",
translation_key="homeassistant_reload",
entity_id="button.homeassistant_reload",
icon="mdi:auto-fix",
entity_category=EntityCategory.CONFIG,
press_fn=lambda hass: hass.services.async_call(
DOMAIN,
SERVICE_RELOAD_ALL,
blocking=True,
),
),
)
async def async_setup_entry(
_hass: HomeAssistant,
_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Spook sensor."""
async_add_entities(
HomeAssistantSpookButtonEntity(description) for description in BUTTONS
)
class HomeAssistantSpookButtonEntity(HomeAssistantSpookEntity, ButtonEntity):
"""Spook button providig Home Asistant actions."""
entity_description: HomeAssistantSpookButtonEntityDescription
async def async_press(self) -> None:
"""Press the button."""
await self.entity_description.press_fn(self.hass)
@@ -0,0 +1,55 @@
"""Device helpers for Home Assistant services."""
from __future__ import annotations
from homeassistant.core import callback
from homeassistant.helpers import device_registry as dr
@callback
def async_disable_device_and_parent_if_needed(
device_registry: dr.DeviceRegistry,
device_id: str,
) -> None:
"""Disable a device and its parent when no enabled child devices remain."""
device = device_registry.async_get(device_id)
if device is None:
return
if device.disabled_by is None:
device_registry.async_update_device(
device_id=device_id,
disabled_by=dr.DeviceEntryDisabler.USER,
)
if device.via_device_id is None:
return
if all(
child.id == device_id or child.disabled_by is not None
for child in device_registry.devices.values()
if child.via_device_id == device.via_device_id
):
async_disable_device_and_parent_if_needed(
device_registry,
device.via_device_id,
)
@callback
def async_enable_device_and_parent(
device_registry: dr.DeviceRegistry,
device_id: str,
) -> None:
"""Enable a device and its parent device chain."""
device = device_registry.async_get(device_id)
if device is None:
return
if device.via_device_id is not None:
async_enable_device_and_parent(device_registry, device.via_device_id)
device_registry.async_update_device(
device_id=device_id,
disabled_by=None,
)
@@ -0,0 +1,25 @@
"""Spook - Your homie."""
from __future__ import annotations
from homeassistant.components import homeassistant
from homeassistant.const import __version__
from homeassistant.helpers.device_registry import DeviceInfo
from ...const import DOMAIN
from ...entity import SpookEntity, SpookEntityDescription
class HomeAssistantSpookEntity(SpookEntity):
"""Defines an base Spook entity for Home Assistant related entities."""
def __init__(self, description: SpookEntityDescription) -> None:
"""Initialize the entity."""
super().__init__(description=description)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, homeassistant.DOMAIN)},
manufacturer="Home Assistant",
name="Home Assistant",
sw_version=__version__,
)
self._attr_unique_id = f"{homeassistant.DOMAIN}_{description.key}"
@@ -0,0 +1,644 @@
"""Spook - Your homie."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from homeassistant.components import (
automation,
input_boolean,
input_button,
input_datetime,
input_number,
input_select,
input_text,
persistent_notification,
person,
script,
sun,
zone,
)
from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
EVENT_COMPONENT_LOADED,
EVENT_HOMEASSISTANT_STARTED,
EntityCategory,
Platform,
)
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers import (
area_registry as ar,
device_registry as dr,
entity_registry as er,
)
from homeassistant.helpers.event import async_call_later
from ...entity import SpookEntityDescription
from ...listeners import async_listen_once_tracked
from .entity import HomeAssistantSpookEntity
if TYPE_CHECKING:
from collections.abc import Callable
from datetime import datetime # Moved datetime here
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util.event_type import EventType
@dataclass(frozen=True, kw_only=True)
class HomeAssistantSpookSensorEntityDescription(
SpookEntityDescription,
SensorEntityDescription,
):
"""Class describing Spook Home Assistant sensor entities."""
value_fn: Callable[[HomeAssistant], int | None]
update_events: set[EventType[Any] | str] = field(default_factory=set)
@callback
def _count_active_domain_entities(hass: HomeAssistant, domain: str) -> int:
"""Count domain entities that are not restored placeholders."""
return sum(
(state := hass.states.get(entity_id)) is not None
and not state.attributes.get("restored", False)
for entity_id in hass.states.async_entity_ids(domain)
)
SENSORS: tuple[HomeAssistantSpookSensorEntityDescription, ...] = (
HomeAssistantSpookSensorEntityDescription(
key=Platform.AIR_QUALITY,
translation_key="homeassistant_air_quality",
entity_id="sensor.air_quality",
icon="mdi:air-filter",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.AIR_QUALITY)),
),
HomeAssistantSpookSensorEntityDescription(
key=Platform.ALARM_CONTROL_PANEL,
translation_key="homeassistant_alarm_control_panel",
entity_id="sensor.alarm_control_panels",
icon="mdi:alarm-panel",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(
hass.states.async_entity_ids(Platform.ALARM_CONTROL_PANEL),
),
),
HomeAssistantSpookSensorEntityDescription(
key="area",
translation_key="homeassistant_area",
entity_id="sensor.areas",
icon="mdi:texture-box",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, ar.EVENT_AREA_REGISTRY_UPDATED},
value_fn=lambda hass: len(list(ar.async_get(hass).async_list_areas())),
),
HomeAssistantSpookSensorEntityDescription(
key=automation.DOMAIN,
translation_key="homeassistant_automation",
entity_id="sensor.automations",
icon="mdi:robot",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={automation.EVENT_AUTOMATION_RELOADED},
value_fn=lambda hass: _count_active_domain_entities(hass, automation.DOMAIN),
),
HomeAssistantSpookSensorEntityDescription(
key=Platform.BINARY_SENSOR,
translation_key="homeassistant_binary_sensor",
entity_id="sensor.binary_sensors",
icon="mdi:numeric-10",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.BINARY_SENSOR)),
),
HomeAssistantSpookSensorEntityDescription(
key=Platform.BUTTON,
translation_key="homeassistant_button",
entity_id="sensor.buttons",
icon="mdi:gesture-tap",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.BUTTON)),
),
HomeAssistantSpookSensorEntityDescription(
key=Platform.CALENDAR,
translation_key="homeassistant_calendar",
entity_id="sensor.calendars",
icon="mdi:calendar",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.CALENDAR)),
),
HomeAssistantSpookSensorEntityDescription(
key=Platform.CAMERA,
translation_key="homeassistant_camera",
entity_id="sensor.cameras",
icon="mdi:cctv",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.CAMERA)),
),
HomeAssistantSpookSensorEntityDescription(
key=Platform.CLIMATE,
translation_key="homeassistant_climate",
entity_id="sensor.climate",
icon="mdi:thermostat",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.CLIMATE)),
),
HomeAssistantSpookSensorEntityDescription(
key=Platform.COVER,
translation_key="homeassistant_cover",
entity_id="sensor.covers",
icon="mdi:blinds",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.COVER)),
),
HomeAssistantSpookSensorEntityDescription(
key=Platform.DATE,
translation_key="homeassistant_date",
entity_id="sensor.dates",
icon="mdi:calendar-month-outline",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.DATE)),
),
HomeAssistantSpookSensorEntityDescription(
key=Platform.DATETIME,
translation_key="homeassistant_datetime",
entity_id="sensor.datetimes",
icon="mdi:calendar-clock",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.DATETIME)),
),
HomeAssistantSpookSensorEntityDescription(
key="device",
translation_key="homeassistant_device",
entity_id="sensor.devices",
icon="mdi:cellphone",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, dr.EVENT_DEVICE_REGISTRY_UPDATED},
value_fn=lambda hass: len(dr.async_get(hass).devices),
),
HomeAssistantSpookSensorEntityDescription(
key=Platform.DEVICE_TRACKER,
translation_key="homeassistant_device_tracker",
entity_id="sensor.device_trackers",
icon="mdi:cellphone-marker",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(
hass.states.async_entity_ids(Platform.DEVICE_TRACKER),
),
),
HomeAssistantSpookSensorEntityDescription(
key="entities",
translation_key="homeassistant_entities",
entity_id="sensor.entities",
icon="mdi:counter",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids()),
),
HomeAssistantSpookSensorEntityDescription(
key=Platform.FAN,
translation_key="homeassistant_fan",
entity_id="sensor.fans",
icon="mdi:fan",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.FAN)),
),
HomeAssistantSpookSensorEntityDescription(
key=Platform.HUMIDIFIER,
translation_key="homeassistant_humidifier",
entity_id="sensor.humidifiers",
icon="mdi:air-humidifier",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.HUMIDIFIER)),
),
HomeAssistantSpookSensorEntityDescription(
key="integration",
translation_key="homeassistant_integration",
entity_id="sensor.integrations",
icon="mdi:package-variant-closed",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED},
value_fn=lambda hass: len(hass.data["entity_platform"]),
),
HomeAssistantSpookSensorEntityDescription(
key="custom_component",
translation_key="homeassistant_custom_component",
entity_id="sensor.custom_integrations",
icon="mdi:package-variant-closed",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED},
value_fn=lambda hass: len(hass.data["custom_components"]),
),
HomeAssistantSpookSensorEntityDescription(
key=input_boolean.DOMAIN,
translation_key="homeassistant_input_boolean",
entity_id="sensor.input_booleans",
icon="mdi:toggle-switch-outline",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(input_boolean.DOMAIN)),
),
HomeAssistantSpookSensorEntityDescription(
key=input_button.DOMAIN,
translation_key="homeassistant_input_button",
entity_id="sensor.input_buttons",
icon="mdi:gesture-tap-button",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(input_button.DOMAIN)),
),
HomeAssistantSpookSensorEntityDescription(
key=input_datetime.DOMAIN,
translation_key="homeassistant_input_datetime",
entity_id="sensor.input_datetimes",
icon="mdi:clock",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(input_datetime.DOMAIN)),
),
HomeAssistantSpookSensorEntityDescription(
key=input_number.DOMAIN,
translation_key="homeassistant_input_number",
entity_id="sensor.input_numbers",
icon="mdi:ray-vertex",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(input_number.DOMAIN)),
),
HomeAssistantSpookSensorEntityDescription(
key=input_select.DOMAIN,
translation_key="homeassistant_input_select",
entity_id="sensor.input_selects",
icon="mdi:form-dropdown",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(input_select.DOMAIN)),
),
HomeAssistantSpookSensorEntityDescription(
key=input_text.DOMAIN,
translation_key="homeassistant_input_text",
entity_id="sensor.input_texts",
icon="mdi:form-textbox",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(input_text.DOMAIN)),
),
HomeAssistantSpookSensorEntityDescription(
key=Platform.IMAGE,
translation_key="homeassistant_image",
entity_id="sensor.images",
icon="mdi:image",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.IMAGE)),
),
HomeAssistantSpookSensorEntityDescription(
key=Platform.LIGHT,
translation_key="homeassistant_light",
entity_id="sensor.lights",
icon="mdi:lightbulb",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.LIGHT)),
),
HomeAssistantSpookSensorEntityDescription(
key=Platform.LOCK,
translation_key="homeassistant_lock",
entity_id="sensor.locks",
icon="mdi:lock",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.LOCK)),
),
HomeAssistantSpookSensorEntityDescription(
key=Platform.MEDIA_PLAYER,
translation_key="homeassistant_media_player",
entity_id="sensor.media_players",
icon="mdi:record-player",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.MEDIA_PLAYER)),
),
HomeAssistantSpookSensorEntityDescription(
key=Platform.NUMBER,
translation_key="homeassistant_number",
entity_id="sensor.numbers",
icon="mdi:ray-vertex",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.NUMBER)),
),
HomeAssistantSpookSensorEntityDescription(
key="persistent_notification",
translation_key="homeassistant_persistent_notification",
entity_id="sensor.persistent_notifications",
icon="mdi:bell-ring-outline",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={
"persistent_notifications_updated",
},
value_fn=lambda hass: len(
# pylint: disable-next=protected-access
persistent_notification._async_get_or_create_notifications( # noqa: SLF001
hass,
),
),
),
HomeAssistantSpookSensorEntityDescription(
key=person.DOMAIN,
translation_key="homeassistant_person",
entity_id="sensor.persons",
icon="mdi:account-group",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(person.DOMAIN)),
),
HomeAssistantSpookSensorEntityDescription(
key=Platform.REMOTE,
translation_key="homeassistant_remote",
entity_id="sensor.remotes",
icon="mdi:remote",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.REMOTE)),
),
HomeAssistantSpookSensorEntityDescription(
key=Platform.SCENE,
translation_key="homeassistant_scene",
entity_id="sensor.scenes",
icon="mdi:palette",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.SCENE)),
),
HomeAssistantSpookSensorEntityDescription(
key=script.DOMAIN,
translation_key="homeassistant_script",
entity_id="sensor.scripts",
icon="mdi:script-text",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: _count_active_domain_entities(hass, script.DOMAIN),
),
HomeAssistantSpookSensorEntityDescription(
key=Platform.SELECT,
translation_key="homeassistant_select",
entity_id="sensor.selects",
icon="mdi:format-list-bulleted",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.SELECT)),
),
HomeAssistantSpookSensorEntityDescription(
key=Platform.SENSOR,
translation_key="homeassistant_sensor",
entity_id="sensor.sensors",
icon="mdi:eye",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.SENSOR)),
),
HomeAssistantSpookSensorEntityDescription(
key=Platform.SIREN,
translation_key="homeassistant_siren",
entity_id="sensor.sirens",
icon="mdi:bullhorn",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.SIREN)),
),
HomeAssistantSpookSensorEntityDescription(
key=sun.DOMAIN,
translation_key="homeassistant_sun",
entity_id="sensor.suns",
icon="mdi:emoticon-cool",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED},
value_fn=lambda hass: len(hass.states.async_entity_ids(sun.DOMAIN)),
),
HomeAssistantSpookSensorEntityDescription(
key=Platform.STT,
translation_key="homeassistant_stt",
entity_id="sensor.stt",
icon="mdi:microphone-message",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.STT)),
),
HomeAssistantSpookSensorEntityDescription(
key=Platform.SWITCH,
translation_key="homeassistant_switch",
entity_id="sensor.switches",
icon="mdi:toggle-switch",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.SWITCH)),
),
HomeAssistantSpookSensorEntityDescription(
key=Platform.TEXT,
translation_key="homeassistant_text",
entity_id="sensor.texts",
icon="mdi:form-textbox",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.TEXT)),
),
HomeAssistantSpookSensorEntityDescription(
key=Platform.TIME,
translation_key="homeassistant_time",
entity_id="sensor.times",
icon="mdi:clock-time-eight-outline",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.TIME)),
),
HomeAssistantSpookSensorEntityDescription(
key=Platform.TODO,
translation_key="homeassistant_todo",
entity_id="sensor.todos",
icon="mdi:clipboard-list",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.TODO)),
),
HomeAssistantSpookSensorEntityDescription(
key=Platform.TTS,
translation_key="homeassistant_tts",
entity_id="sensor.tts",
icon="mdi:speaker-message",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.TTS)),
),
HomeAssistantSpookSensorEntityDescription(
key=Platform.VACUUM,
translation_key="homeassistant_vacuum",
entity_id="sensor.vacuums",
icon="mdi:vacuum",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.VACUUM)),
),
HomeAssistantSpookSensorEntityDescription(
key=Platform.UPDATE,
translation_key="homeassistant_update",
entity_id="sensor.update",
icon="mdi:cellphone-arrow-down",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.UPDATE)),
),
HomeAssistantSpookSensorEntityDescription(
key=Platform.WATER_HEATER,
translation_key="homeassistant_water_heater",
entity_id="sensor.water_heaters",
icon="mdi:water-boiler",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.WATER_HEATER)),
),
HomeAssistantSpookSensorEntityDescription(
key=Platform.WEATHER,
translation_key="homeassistant_weather",
entity_id="sensor.weather",
icon="mdi:weather-cloudy",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.WEATHER)),
),
HomeAssistantSpookSensorEntityDescription(
key=zone.DOMAIN,
translation_key="homeassistant_zone",
entity_id="sensor.zones",
icon="mdi:selection-marker",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(zone.DOMAIN)),
),
)
async def async_setup_entry(
_hass: HomeAssistant,
_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Spook sensor."""
async_add_entities(
HomeAssistantSpookSensorEntity(description) for description in SENSORS
)
class HomeAssistantSpookSensorEntity(HomeAssistantSpookEntity, SensorEntity):
"""Spook sensor providig Home Asistant information."""
entity_description: HomeAssistantSpookSensorEntityDescription
_unsub_debouncer: Callable[[], None] | None = None
async def async_added_to_hass(self) -> None:
"""Register for sensor updates."""
@callback
def _debounced_update(
_now: datetime | None = None,
) -> None:
"""Update state after debounce."""
self._unsub_debouncer = None
self.async_schedule_update_ha_state()
@callback
def _update_state(_: Event) -> None:
"""Update state."""
if self._unsub_debouncer:
self._unsub_debouncer()
self._unsub_debouncer = async_call_later(self.hass, 5, _debounced_update)
for event in self.entity_description.update_events:
self.async_on_remove(self.hass.bus.async_listen(event, _update_state))
self.async_on_remove(
async_listen_once_tracked(
self.hass, EVENT_HOMEASSISTANT_STARTED, _update_state
),
)
async def async_will_remove_from_hass(self) -> None:
"""Clean up debounce timer."""
if self._unsub_debouncer:
self._unsub_debouncer()
self._unsub_debouncer = None
await super().async_will_remove_from_hass()
@property
def native_value(self) -> int | None:
"""Return the sensor value."""
return self.entity_description.value_fn(self.hass)
@@ -0,0 +1 @@
"""Spook - Your homie."""

Some files were not shown because too many files have changed in this diff Show More