361 files

This commit is contained in:
Home Assistant Version Control
2026-10-06 17:11:43 +00:00
parent d2c5af43e3
commit bf886c9347
362 changed files with 22060 additions and 1285 deletions
+31 -2
View File
@@ -18,6 +18,7 @@ from homeassistant.helpers import issue_registry as ir
from .automation_runs import async_setup_automation_runs
from .const import DOMAIN, LOGGER, PLATFORMS
from .dismissals import async_setup_dismissals
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
@@ -90,10 +91,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# spanned a restart would be a disable nobody remembers making.
entry.async_on_unload(await async_setup_timed_states(hass))
# Set up services
# Set up services. Unloading is registered first: setting up starts
# listening before it is done, and Home Assistant runs these callbacks
# when a setup fails or is cancelled too. Registered afterwards, a failed
# setup would leave the manager listening and registering actions for a
# Spook that never loaded.
services = SpookServiceManager(hass)
await services.async_setup()
entry.async_on_unload(services.async_on_unload)
await services.async_setup()
# Watching before anything can start a wait it has to observe. A repair
# can put a statistic on the clock the first time it looks, and on the
@@ -102,12 +107,36 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# of what it is meant to be watching.
entry.async_on_unload(async_setup_abandoned_statistics_watching(hass))
# Loaded before any repair looks, because the first thing a repair does
# with what it finds is check whether somebody already said to leave it.
entry.async_on_unload(await async_setup_dismissals(hass))
# Who you gonna call? SpookRepairManager!
repairs = SpookRepairManager(hass)
# Starting the repairs takes a moment, and on a normal start that moment
# comes after this setup is long done. Spook can be disabled or reloaded
# in it, and the unload that runs then has not been told about repairs
# that are not there yet. They finished starting afterwards with nothing
# left to stop them.
unloaded = False
@callback
def _note_the_unload() -> None:
"""Remember Spook went, for repairs that were still on their way."""
nonlocal unloaded
unloaded = True
entry.async_on_unload(_note_the_unload)
async def _ghost_busters(_: Event | None = None) -> None:
"""Send them in, time for some ghost chasing."""
await repairs.async_setup()
if unloaded:
await repairs.async_on_unload()
return
entry.async_on_unload(repairs.async_on_unload)
if hass.state == CoreState.running:
@@ -1 +1,70 @@
"""Spook - Your homie."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.components import automation
from homeassistant.const import EVENT_STATE_CHANGED, STATE_OFF, STATE_ON
from homeassistant.core import callback
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
if TYPE_CHECKING:
from collections.abc import Mapping
from homeassistant.core import Event
class AbstractSpookAutomationReferencesRepair(
AbstractSpookEntityComponentUnknownReferencesRepair
):
"""Base for the repairs that look for unknown references in automations.
An automation somebody turned off is left alone. It does nothing while it
is off, so nothing in it can go wrong yet, and it was often turned off
exactly because something in it is broken: being told about that again
and again is noise. The moment it is turned back on is the moment it
matters, so that is when it is looked at again. #1725.
"""
def _should_inspect_entity(self, entity: Any) -> bool:
"""Look only at automations that are on.
One that failed to load is not off, it is broken, and some of these
repairs exist to say exactly why. That is not somebody's choice to
leave it alone, so it is still looked at.
"""
if not isinstance(entity, automation.AutomationEntity):
return True
return bool(entity.is_on)
async def async_activate(self) -> None:
"""Look again whenever an automation is turned on or off.
Turned on, it is reported straight away rather than at the next look.
Turned off, what was reported for it is cleared then too.
"""
await super().async_activate()
@callback
def _turned_on_or_off(event_data: Mapping[str, Any]) -> bool:
"""Return whether an automation went from off to on, or back."""
if not event_data["entity_id"].startswith(f"{automation.DOMAIN}."):
return False
if (old := event_data["old_state"]) is None or (
new := event_data["new_state"]
) is None:
return False
# Every run updates an automation's attributes, and those are not
# a reason to look; only the switch between on and off is.
return {old.state, new.state} == {STATE_ON, STATE_OFF}
async def _look_again(_: Event) -> None:
await self.inspect_debouncer.async_call()
self._event_subs.add(
self.hass.bus.async_listen(
EVENT_STATE_CHANGED, _look_again, event_filter=_turned_on_or_off
)
)
@@ -9,13 +9,13 @@ from homeassistant.helpers import area_registry as ar
from ....entity_filtering import async_filter_known_area_ids, async_get_all_area_ids
from ....reference_extraction import extract_targets_from_config
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
from . import AbstractSpookAutomationReferencesRepair
if TYPE_CHECKING:
from typing import Any
class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
class SpookRepair(AbstractSpookAutomationReferencesRepair):
"""Spook repair tries to find unknown referenced areas in automations."""
domain = automation.DOMAIN
@@ -9,8 +9,8 @@ from homeassistant.helpers import device_registry as dr
from ....entity_filtering import async_filter_known_device_ids, async_get_all_device_ids
from ....reference_extraction import extract_targets_from_config
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
from ....template_extraction import extract_device_ids_from_config
from . import AbstractSpookAutomationReferencesRepair
def extract_event_data_device_ids_from_trigger_config(
@@ -46,7 +46,7 @@ def extract_event_data_device_ids_from_trigger_config(
return device_ids
class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
class SpookRepair(AbstractSpookAutomationReferencesRepair):
"""Spook repair tries to find unknown referenced devices in automations."""
domain = automation.DOMAIN
@@ -5,8 +5,7 @@ from __future__ import annotations
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.const import EVENT_COMPONENT_LOADED
from homeassistant.helpers import entity_registry as er
from ....action_extraction import (
@@ -14,16 +13,14 @@ from ....action_extraction import (
async_extract_entities_from_value,
)
from ....entity_filtering import async_get_all_entity_ids, async_get_all_services
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
from ....template_extraction import (
KNOWN_DOMAINS,
async_extract_entities_from_config,
async_filter_known_entity_ids_with_templates,
)
from . import AbstractSpookAutomationReferencesRepair
if TYPE_CHECKING:
from collections.abc import Mapping
from homeassistant.core import HomeAssistant
@@ -260,7 +257,7 @@ async def extract_entities_from_condition_config(
return entities
class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
class SpookRepair(AbstractSpookAutomationReferencesRepair):
"""Spook repair tries to find unknown referenced entity in automations."""
domain = automation.DOMAIN
@@ -271,6 +268,7 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
}
inspect_config_entry_changed = True
inspect_on_reload = True
inspect_on_entity_added_or_removed = True
unavailable_entity_class = automation.UnavailableAutomationEntity
entity_label = "automation"
@@ -281,31 +279,6 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
_known_entity_ids: set[str]
_known_services: 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 what every automation in this cycle needs looked up.
@@ -318,10 +291,6 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
)
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 entity IDs referenced by ``entity`` (incl. templates)."""
all_entities = set(entity.referenced_entities)
@@ -9,13 +9,13 @@ from homeassistant.helpers import floor_registry as fr
from ....entity_filtering import async_filter_known_floor_ids, async_get_all_floor_ids
from ....reference_extraction import extract_targets_from_config
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
from . import AbstractSpookAutomationReferencesRepair
if TYPE_CHECKING:
from typing import Any
class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
class SpookRepair(AbstractSpookAutomationReferencesRepair):
"""Spook repair tries to find unknown referenced floors in automations."""
domain = automation.DOMAIN
@@ -9,13 +9,13 @@ from homeassistant.helpers import label_registry as lr
from ....entity_filtering import async_filter_known_label_ids, async_get_all_label_ids
from ....reference_extraction import extract_targets_from_config
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
from . import AbstractSpookAutomationReferencesRepair
if TYPE_CHECKING:
from typing import Any
class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
class SpookRepair(AbstractSpookAutomationReferencesRepair):
"""Spook repair tries to find unknown referenced labels in automations."""
domain = automation.DOMAIN
@@ -16,13 +16,13 @@ from ....entity_filtering import (
async_find_services_in_sequence,
async_get_all_services,
)
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
from . import AbstractSpookAutomationReferencesRepair
if TYPE_CHECKING:
from typing import Any
class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
class SpookRepair(AbstractSpookAutomationReferencesRepair):
"""Spook repair tries to find unknown referenced services in automations."""
domain = automation.DOMAIN
@@ -47,10 +47,6 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
"""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(
@@ -39,9 +39,12 @@ class SpookRepair(AbstractSpookRepair):
if not (platforms := self.hass.data[DATA_ENTITY_PLATFORM].get(self.domain)):
return # Nothing to do.
for platform in platforms:
# Taken as a snapshot: describing what is missing can hand the event
# loop a turn, and a change to the live collection during it ends the
# inspection in a `RuntimeError`. #1558.
for platform in list(platforms):
# We don't want to check the old style group platform
for entity in platform.entities.values():
for entity in list(platform.entities.values()):
self.possible_issue_ids.add(entity.entity_id)
members = []
if platform.domain == group.DOMAIN:
@@ -56,7 +59,7 @@ class SpookRepair(AbstractSpookRepair):
if unknown_entities := async_filter_known_entity_ids(
self.hass, entity_ids=members, known_entity_ids=known_entity_ids
):
described = async_describe_unknown_entities(
described = await async_describe_unknown_entities(
self.hass, sorted(unknown_entities)
)
self.async_create_issue(
@@ -65,6 +68,10 @@ class SpookRepair(AbstractSpookRepair):
is_fixable=True,
data={
"group_entity_id": entity.entity_id,
# What the fix checks again before dropping any.
"group_unknown_entity_ids": ",".join(
sorted(unknown_entities)
),
"group": entity.name,
"entities": described,
},
@@ -47,4 +47,11 @@ class SpookService(AbstractSpookAdminService, ReplaceExistingService):
msg,
)
self.hass.async_run_hass_job(self.overriden_service.job, call)
# Waited on, the way calling Home Assistant's own restart is. It
# checks the configuration first and refuses when that is broken, and
# without waiting that refusal went nowhere: the automation carried
# on as if Home Assistant was on its way back up.
if (
running := self.hass.async_run_hass_job(self.overriden_service.job, call)
) is not None:
await running
@@ -8,9 +8,8 @@ from homeassistant.components.lovelace import DOMAIN
from homeassistant.const import (
EVENT_COMPONENT_LOADED,
EVENT_LOVELACE_UPDATED,
EVENT_STATE_CHANGED,
)
from homeassistant.core import Event, callback
from homeassistant.core import callback
from homeassistant.helpers import entity_registry as er
from ....const import LOGGER
@@ -21,8 +20,6 @@ from ....repairs import AbstractSpookRepair
from ..dashboards import async_dashboard_configs
if TYPE_CHECKING:
from collections.abc import Mapping
from homeassistant.components.lovelace.dashboard import (
LovelaceStorage,
LovelaceYAML,
@@ -41,36 +38,16 @@ class SpookRepair(AbstractSpookRepair):
}
inspect_config_entry_changed = True
inspect_on_reload = True
inspect_on_entity_added_or_removed = True
automatically_clean_up_issues = True
_dashboards: dict[str, LovelaceStorage | LovelaceYAML]
_dashboards: dict[str | None, LovelaceStorage | LovelaceYAML]
async def async_activate(self) -> None:
"""Handle the activating a repair."""
self._dashboards = self.hass.data["lovelace"].dashboards
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_inspect(self) -> None:
"""Trigger a inspection."""
LOGGER.debug("Spook is inspecting: %s", self.repair)
@@ -105,7 +82,7 @@ class SpookRepair(AbstractSpookRepair):
issue_id=url_path,
references=unknown_entities,
translation_placeholders={
"entities": async_describe_unknown_entities(
"entities": await async_describe_unknown_entities(
self.hass, sorted(unknown_entities)
),
"dashboard": title,
@@ -10,10 +10,33 @@ if TYPE_CHECKING:
from homeassistant.components.number import NumberEntity
def native_value_as_float(entity: NumberEntity) -> float:
"""Return the native value of a number entity as a float."""
def shown_value_as_float(entity: NumberEntity) -> float:
"""Return the value of a number entity, as it shows it, as a float.
In the units it shows, which are not always the units it works in: a
thermostat that keeps Celsius can be shown in Fahrenheit. Stepping from
this means stepping in what somebody sees, and the result has to go back
through `async_set_shown_value` to land in the units the entity keeps.
"""
try:
return float(entity.value)
except (TypeError, ValueError) as err:
msg = f"Native value {entity.value!r} for {entity.entity_id} is not a number"
msg = f"Value {entity.value!r} for {entity.entity_id} is not a number"
raise HomeAssistantError(msg) from err
async def async_set_shown_value(entity: NumberEntity, value: float) -> None:
"""Set a value given in the units the number shows, the way core does.
Converted to the units the entity works in, and kept within the limits
it has there. Handing the shown value over as it is set a thermostat
showing 68 °F to 69 °C on a step of one degree.
"""
try:
native_value = entity.convert_to_native_value(value)
native_value = min(
max(native_value, entity.native_min_value), entity.native_max_value
)
await entity.async_set_native_value(native_value)
except NotImplementedError:
await entity.async_set_value(value)
@@ -10,7 +10,7 @@ import voluptuous as vol
from homeassistant.components.number import DOMAIN, NumberEntity
from ....services import AbstractSpookEntityComponentService
from . import native_value_as_float
from . import async_set_shown_value, shown_value_as_float
if TYPE_CHECKING:
from homeassistant.core import ServiceCall
@@ -44,9 +44,9 @@ class SpookService(AbstractSpookEntityComponentService[NumberEntity]):
)
raise ValueError(msg)
value = native_value_as_float(entity) - amount
value = shown_value_as_float(entity) - amount
if entity.min_value is not None:
value = max(value, entity.min_value)
await entity.async_set_native_value(value)
await async_set_shown_value(entity, value)
@@ -10,7 +10,7 @@ import voluptuous as vol
from homeassistant.components.number import DOMAIN, NumberEntity
from ....services import AbstractSpookEntityComponentService
from . import native_value_as_float
from . import async_set_shown_value, shown_value_as_float
if TYPE_CHECKING:
from homeassistant.core import ServiceCall
@@ -44,9 +44,9 @@ class SpookService(AbstractSpookEntityComponentService[NumberEntity]):
)
raise ValueError(msg)
value = native_value_as_float(entity) + amount
value = shown_value_as_float(entity) + amount
if entity.max_value is not None:
value = min(value, entity.max_value)
await entity.async_set_native_value(value)
await async_set_shown_value(entity, value)
@@ -54,7 +54,7 @@ class SpookRepair(AbstractSpookRepair):
references=unknown_entities,
translation_placeholders={
"name": coordinator.name,
"zones": async_describe_unknown_entities(
"zones": await async_describe_unknown_entities(
self.hass, sorted(unknown_entities)
),
},
@@ -54,7 +54,7 @@ class SpookRepair(AbstractSpookRepair):
references=unknown_entities,
translation_placeholders={
"name": coordinator.name,
"entities": async_describe_unknown_entities(
"entities": await async_describe_unknown_entities(
self.hass, sorted(unknown_entities)
),
},
@@ -43,7 +43,10 @@ class SpookRepair(AbstractSpookRepair):
known_entity_ids = async_get_all_entity_ids(self.hass)
for entity in scenes:
# Taken as a snapshot: describing what is missing can hand the event
# loop a turn, and a change to the live collection during it ends the
# inspection in a `RuntimeError`. #1558.
for entity in list(scenes):
self.possible_issue_ids.add(entity.entity_id)
if unknown_entities := async_filter_known_entity_ids(
self.hass,
@@ -62,7 +65,7 @@ class SpookRepair(AbstractSpookRepair):
issue_id=entity.entity_id,
references=unknown_entities,
translation_placeholders={
"entities": async_describe_unknown_entities(
"entities": await async_describe_unknown_entities(
self.hass, sorted(unknown_entities)
),
"scene": entity.name,
@@ -28,6 +28,10 @@ class SpookService(AbstractSpookEntityComponentService[Timer]):
domain = DOMAIN
service = "set_duration"
# This changes the timer as it is stored, which editing it in the UI only
# lets an admin do. Starting or pausing it is somebody using it, this is
# somebody changing it.
admin_only = True
schema = {
vol.Required(CONF_DURATION): cv.time_period,
}
+12
View File
@@ -32,12 +32,21 @@
"cron": {
"trigger": "mdi:calendar-clock"
},
"device_added": {
"trigger": "mdi:devices"
},
"device_discovered": {
"trigger": "mdi:magnify-plus"
},
"debounce": {
"trigger": "mdi:sine-wave"
},
"flapping": {
"trigger": "mdi:bird"
},
"integration_added": {
"trigger": "mdi:puzzle-plus"
},
"integration_failed": {
"trigger": "mdi:trophy-broken"
},
@@ -61,6 +70,9 @@
},
"while": {
"trigger": "mdi:repeat"
},
"user_added": {
"trigger": "mdi:account-plus"
}
}
}
@@ -0,0 +1,14 @@
{
"domain": "spook_calibration",
"name": "Calibration 👻",
"codeowners": [
"@frenck"
],
"config_flow": true,
"documentation": "https://spook.boo",
"integration_type": "helper",
"iot_class": "calculated",
"issue_tracker": "https://github.com/frenck/spook/issues",
"requirements": [],
"version": "5.8.1"
}
@@ -0,0 +1,37 @@
{
"config": {
"step": {
"user": {
"data": {
"entity_id": "Quell-Entität",
"name": "Name",
"offset": "Offset",
"factor": "Faktor"
},
"data_description": {
"offset": "Wird zum Wert der Quelle addiert. Ein Sensor, der 1,5 Grad zu hoch misst, bekommt -1,5. In der Einheit, in der die Quelle misst.",
"factor": "Der Wert der Quelle wird zuerst damit multipliziert. Lass ihn auf 1, um nur den Offset zu addieren."
},
"description": "Dieser Helfer korrigiert einen Sensor, der danebenliegt: eine Temperatur ein Grad zu hoch, eine Luftfeuchtigkeit ein paar Prozent zu niedrig. Er zeigt den Wert der Quelle mal den Faktor, plus den Offset.",
"title": "Kalibrierung 👻"
}
}
},
"options": {
"step": {
"init": {
"data": {
"entity_id": "Quell-Entität",
"offset": "Offset",
"factor": "Faktor"
},
"data_description": {
"offset": "Wird zum Wert der Quelle addiert. Ein Sensor, der 1,5 Grad zu hoch misst, bekommt -1,5. In der Einheit, in der die Quelle misst.",
"factor": "Der Wert der Quelle wird zuerst damit multipliziert. Lass ihn auf 1, um nur den Offset zu addieren."
},
"title": "Kalibrierung 👻"
}
}
},
"title": "Kalibrierung 👻"
}
@@ -0,0 +1,37 @@
{
"config": {
"step": {
"user": {
"data": {
"entity_id": "Source entity",
"name": "Name",
"offset": "Offset",
"factor": "Factor"
},
"data_description": {
"offset": "Added to the source's value. A sensor reading 1.5 degrees too high gets -1.5. In the unit the source reports in.",
"factor": "The source's value is multiplied by this first. Leave at 1 to only add the offset."
},
"description": "This helper corrects a sensor that reads off: a temperature a degree too high, a humidity a few percent too low. It shows the source's value times the factor, plus the offset.",
"title": "Calibration 👻"
}
}
},
"options": {
"step": {
"init": {
"data": {
"entity_id": "Source entity",
"offset": "Offset",
"factor": "Factor"
},
"data_description": {
"offset": "Added to the source's value. A sensor reading 1.5 degrees too high gets -1.5. In the unit the source reports in.",
"factor": "The source's value is multiplied by this first. Leave at 1 to only add the offset."
},
"title": "Calibration 👻"
}
}
},
"title": "Calibration 👻"
}
@@ -0,0 +1,37 @@
{
"config": {
"step": {
"user": {
"data": {
"entity_id": "Entidad origen",
"name": "Nombre",
"offset": "Desplazamiento",
"factor": "Factor"
},
"data_description": {
"offset": "Se suma al valor del origen. Un sensor que mide 1,5 grados de más recibe -1,5. En la unidad en la que informa el origen.",
"factor": "El valor del origen se multiplica primero por este número. Déjalo en 1 para solo sumar el desplazamiento."
},
"description": "Este ayudante corrige un sensor que no mide bien: una temperatura un grado demasiado alta, una humedad unos puntos demasiado baja. Muestra el valor del origen multiplicado por el factor, más el desplazamiento.",
"title": "Calibración 👻"
}
}
},
"options": {
"step": {
"init": {
"data": {
"entity_id": "Entidad origen",
"offset": "Desplazamiento",
"factor": "Factor"
},
"data_description": {
"offset": "Se suma al valor del origen. Un sensor que mide 1,5 grados de más recibe -1,5. En la unidad en la que informa el origen.",
"factor": "El valor del origen se multiplica primero por este número. Déjalo en 1 para solo sumar el desplazamiento."
},
"title": "Calibración 👻"
}
}
},
"title": "Calibración 👻"
}
@@ -0,0 +1,37 @@
{
"config": {
"step": {
"user": {
"data": {
"entity_id": "Entité d'origine",
"name": "Nom",
"offset": "Décalage",
"factor": "Facteur"
},
"data_description": {
"offset": "Ajouté à la valeur de l'entité d'origine. Un capteur qui mesure 1,5 degré de trop reçoit -1,5. Dans l'unité de l'entité d'origine.",
"factor": "La valeur de l'entité d'origine est d'abord multipliée par ce facteur. Laisser à 1 pour n'ajouter que le décalage."
},
"description": "Cette entrée corrige un capteur qui se trompe, comme une température trop haute d'un degré ou une humidité trop basse de quelques pour cent. Elle affiche la valeur de l'entité d'origine multipliée par le facteur, plus le décalage.",
"title": "Étalonnage 👻"
}
}
},
"options": {
"step": {
"init": {
"data": {
"entity_id": "Entité d'origine",
"offset": "Décalage",
"factor": "Facteur"
},
"data_description": {
"offset": "Ajouté à la valeur de l'entité d'origine. Un capteur qui mesure 1,5 degré de trop reçoit -1,5. Dans l'unité de l'entité d'origine.",
"factor": "La valeur de l'entité d'origine est d'abord multipliée par ce facteur. Laisser à 1 pour n'ajouter que le décalage."
},
"title": "Étalonnage 👻"
}
}
},
"title": "Étalonnage 👻"
}
@@ -0,0 +1,37 @@
{
"config": {
"step": {
"user": {
"data": {
"entity_id": "Bronentiteit",
"name": "Naam",
"offset": "Offset",
"factor": "Factor"
},
"data_description": {
"offset": "Wordt opgeteld bij de waarde van de bron. Een sensor die 1,5 graad te hoog meet, krijgt -1,5. In de eenheid waarin de bron rapporteert.",
"factor": "De waarde van de bron wordt hier eerst mee vermenigvuldigd. Laat op 1 staan om alleen de offset op te tellen."
},
"description": "Deze helper corrigeert een sensor die afwijkt: een temperatuur die een graad te hoog is, een luchtvochtigheid die een paar procent te laag is. Hij toont de waarde van de bron maal de factor, plus de offset.",
"title": "Kalibratie 👻"
}
}
},
"options": {
"step": {
"init": {
"data": {
"entity_id": "Bronentiteit",
"offset": "Offset",
"factor": "Factor"
},
"data_description": {
"offset": "Wordt opgeteld bij de waarde van de bron. Een sensor die 1,5 graad te hoog meet, krijgt -1,5. In de eenheid waarin de bron rapporteert.",
"factor": "De waarde van de bron wordt hier eerst mee vermenigvuldigd. Laat op 1 staan om alleen de offset op te tellen."
},
"title": "Kalibratie 👻"
}
}
},
"title": "Kalibratie 👻"
}
@@ -0,0 +1,37 @@
{
"config": {
"step": {
"user": {
"data": {
"entity_id": "Изворни ентитет",
"name": "Назив",
"offset": "Одступање",
"factor": "Фактор"
},
"data_description": {
"offset": "Додаје се на вредност извора. Сензор који показује 1,5 степени више добија -1,5. Користи се мерна јединица коју пријављује извор.",
"factor": "Вредност извора се прво множи овим бројем. Оставите 1 ако желите да додате само одступање."
},
"title": "Калибрација 👻",
"description": "Овај помоћник исправља сензор који лоше очитава: температуру која је за степен превисока или влажност која је неколико процената прениска. Приказује вредност извора помножену са фактором, плус одступање."
}
}
},
"options": {
"step": {
"init": {
"data_description": {
"offset": "Додаје се на вредност извора. Сензор који показује 1,5 степени више добија -1,5. Користи се мерна јединица коју пријављује извор.",
"factor": "Вредност извора се прво множи овим бројем. Оставите 1 ако желите да додате само одступање."
},
"data": {
"factor": "Фактор",
"offset": "Одступање",
"entity_id": "Изворни ентитет"
},
"title": "Калибрација 👻"
}
}
},
"title": "Калибрација 👻"
}
@@ -0,0 +1,37 @@
{
"title": "Kalibracija👻",
"options": {
"step": {
"init": {
"title": "Kalibracija👻",
"data": {
"offset": "Одступање",
"factor": "Faktor",
"entity_id": "Izvorni entitet"
},
"data_description": {
"offset": "Dodaje se na vrednost izvora. Senzor koji pokazuje 1,5 stepeni više dobija -1,5. Koristi se merna jedinica koju prijavljuje izvor.",
"factor": "Vrednost izvora se prvo množi ovim brojem. Ostavite 1 ako želite da dodate samo odstupanje."
}
}
}
},
"config": {
"step": {
"user": {
"title": "Kalibracija👻",
"data": {
"offset": "Odstupanje",
"name": "Naziv",
"factor": "Faktor",
"entity_id": "Izvorni entitet"
},
"data_description": {
"offset": "Dodaje se na vrednost izvora. Senzor koji pokazuje 1,5 stepeni više dobija -1,5. Koristi se merna jedinica koju prijavljuje izvor.",
"factor": "Vrednost izvora se prvo množi ovim brojem. Ostavite 1 ako želite da dodate samo odstupanje."
},
"description": "Ovaj pomoćnik ispravlja senzor koji loše očitava: temperaturu koja je za stepen previsoka ili vlažnost koja je nekoliko procenata preniska. Prikazuje vrednost izvora pomnoženu sa faktorom, plus odstupanje."
}
}
}
}
@@ -7,10 +7,13 @@ from typing import TYPE_CHECKING
from homeassistant.const import CONF_ENTITY_ID
from homeassistant.core import callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.helper_integration import async_remove_helper_devices
from homeassistant.helpers.helper_integration import (
async_handle_source_entity_changes,
async_remove_helper_devices,
)
from .config_flow import SpookInverseConfigFlowHandler
from .const import CONF_HIDE_SOURCE
from .const import CONF_HIDE_SOURCE, DOMAIN
MIGRATION_MINOR_VERSION = SpookInverseConfigFlowHandler.MINOR_VERSION
@@ -36,6 +39,40 @@ def async_get_source_entity_device_id(
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up from a config entry."""
source = entry.options[CONF_ENTITY_ID]
# Remembered for when the options change, which is the only moment the
# previous source, and whether this inverse hid it, is still known.
# Somebody pointing the inverse elsewhere would otherwise leave the old
# one hidden for good.
entry.runtime_data = (source, entry.options[CONF_HIDE_SOURCE])
@callback
def _follow_the_source(source_entity_id: str) -> None:
"""Keep up with the source being renamed, the way core helpers do.
The inverse listens to its source by entity ID, and a rename gives it
a new one: without this it keeps listening to a name nobody uses and
switching something that is not there.
"""
hass.config_entries.async_update_entry(
entry, options={**entry.options, CONF_ENTITY_ID: source_entity_id}
)
# A source stored as a registry ID that is gone resolves to nothing, and
# Home Assistant raises on following that. The inverse still sets up, and
# is unavailable, which is the truth about it.
if er.async_resolve_entity_id(er.async_get(hass), source):
entry.async_on_unload(
async_handle_source_entity_changes(
hass,
helper_config_entry_id=entry.entry_id,
set_source_entity_id_or_uuid=_follow_the_source,
source_device_id=async_get_source_entity_device_id(hass, source),
source_entity_id_or_uuid=source,
)
)
await hass.config_entries.async_forward_entry_setups(
entry,
(entry.options["inverse_type"],),
@@ -45,10 +82,52 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Update listener, called when the config entry options are changed."""
"""Update listener, called when the config entry options are changed.
Hiding happens in the options flow. Showing the source again happens
here, because only here are both the old options and the new ones known,
and showing it again is only this inverse's to do if it was the one that
hid it.
"""
previous_source, previously_hidden = entry.runtime_data
if previously_hidden and (
previous_source != entry.options[CONF_ENTITY_ID]
or not entry.options[CONF_HIDE_SOURCE]
):
async_release_source(hass, previous_source)
await hass.config_entries.async_reload(entry.entry_id)
@callback
def async_release_source(hass: HomeAssistant, source: str) -> None:
"""Show a source again that this inverse hid and no longer hides.
Only if Spook hid it, and only if no other inverse still wants it hidden:
two inverses of one source share the hiding, and one of them letting go
is not the other one letting go. After a rename the old name is in the
registry no more, and the entry that is, with its hiding, is left alone.
"""
registry = er.async_get(hass)
if not (entity_id := er.async_resolve_entity_id(registry, source)):
return
if (entity_entry := registry.async_get(entity_id)) is None:
return
if entity_entry.hidden_by != er.RegistryEntryHider.INTEGRATION:
return
# Every inverse as it is now, this one with its new options. A removed
# one is already gone from the list by the time it is asked about.
for other in hass.config_entries.async_entries(DOMAIN):
if other.options.get(CONF_HIDE_SOURCE) and (
er.async_resolve_entity_id(registry, other.options[CONF_ENTITY_ID])
== entity_id
):
return
registry.async_update_entity(entity_id, hidden_by=None)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(
@@ -59,19 +138,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Remove a config entry, unhide the source entity."""
registry = er.async_get(hass)
if not entry.options[CONF_HIDE_SOURCE]:
return
if not (
entity_id := er.async_resolve_entity_id(registry, entry.options[CONF_ENTITY_ID])
):
return
if (entity_entry := registry.async_get(entity_id)) is None:
return
if entity_entry.hidden_by != er.RegistryEntryHider.INTEGRATION:
return
registry.async_update_entity(entity_id, hidden_by=None)
if entry.options[CONF_HIDE_SOURCE]:
async_release_source(hass, entry.options[CONF_ENTITY_ID])
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
@@ -5,9 +5,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.const import CONF_ENTITY_ID, STATE_ON, STATE_UNKNOWN
from homeassistant.const import STATE_ON, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers import entity_registry as er
from .entity import InverseEntity
@@ -22,10 +21,8 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Initialize inverse config entry."""
er.async_validate_entity_id(
er.async_get(hass),
config_entry.options[CONF_ENTITY_ID],
)
# The source is resolved by the entity itself, which stays unavailable
# rather than failing to set up when the source is not there.
async_add_entities([InverseBinarySensor(hass, config_entry)])
@@ -19,39 +19,61 @@ from homeassistant.helpers.schema_config_entry_flow import (
entity_selector_without_own_entities,
)
from .const import CONF_HIDE_SOURCE, DOMAIN, PLATFORMS
from .const import (
CONF_HIDE_SOURCE,
CONF_INVERSE_POSITION,
CONF_INVERSE_TILT,
DOMAIN,
PLATFORMS,
SOURCE_DOMAINS,
)
if TYPE_CHECKING:
from collections.abc import Callable, Coroutine, Mapping
# A cover moves two ways, and a device can have either of them backwards
# without the other: blinds that close when told to open, or slats that tilt
# the wrong way on blinds that are otherwise fine.
COVER_SCHEMA = {
vol.Required(CONF_INVERSE_POSITION, default=True): selector.BooleanSelector(),
vol.Required(CONF_INVERSE_TILT, default=False): selector.BooleanSelector(),
}
async def options_schema(
domain: str | list[str],
domain: str,
handler: SchemaCommonFlowHandler,
) -> vol.Schema:
"""Generate options schema."""
return vol.Schema(
schema = vol.Schema(
{
vol.Required(CONF_ENTITY_ID): entity_selector_without_own_entities(
cast(SchemaOptionsFlowHandler, handler.parent_handler),
selector.EntitySelectorConfig(domain=domain),
selector.EntitySelectorConfig(domain=SOURCE_DOMAINS[domain]),
),
vol.Required(CONF_HIDE_SOURCE, default=False): selector.BooleanSelector(),
},
)
if domain == Platform.COVER:
schema = schema.extend(COVER_SCHEMA)
return schema
def config_schema(domain: str | list[str]) -> vol.Schema:
def config_schema(domain: str) -> vol.Schema:
"""Generate config schema."""
return vol.Schema(
schema = vol.Schema(
{
vol.Required(CONF_NAME): selector.TextSelector(),
vol.Required(CONF_ENTITY_ID): selector.EntitySelector(
selector.EntitySelectorConfig(domain=domain),
selector.EntitySelectorConfig(domain=SOURCE_DOMAINS[domain]),
),
vol.Required(CONF_HIDE_SOURCE, default=False): selector.BooleanSelector(),
},
)
if domain == Platform.COVER:
schema = schema.extend(COVER_SCHEMA)
return schema
async def choose_options_step(options: dict[str, Any]) -> str:
@@ -83,10 +105,18 @@ CONFIG_FLOW = {
config_schema(Platform.BINARY_SENSOR),
validate_user_input=set_inverse_type(Platform.BINARY_SENSOR),
),
Platform.COVER: SchemaFlowFormStep(
config_schema(Platform.COVER),
validate_user_input=set_inverse_type(Platform.COVER),
),
Platform.SWITCH: SchemaFlowFormStep(
config_schema(Platform.SWITCH),
validate_user_input=set_inverse_type(Platform.SWITCH),
),
Platform.VALVE: SchemaFlowFormStep(
config_schema(Platform.VALVE),
validate_user_input=set_inverse_type(Platform.VALVE),
),
}
@@ -95,7 +125,9 @@ OPTIONS_FLOW = {
Platform.BINARY_SENSOR: SchemaFlowFormStep(
partial(options_schema, Platform.BINARY_SENSOR),
),
Platform.COVER: SchemaFlowFormStep(partial(options_schema, Platform.COVER)),
Platform.SWITCH: SchemaFlowFormStep(partial(options_schema, Platform.SWITCH)),
Platform.VALVE: SchemaFlowFormStep(partial(options_schema, Platform.VALVE)),
}
@@ -129,22 +161,32 @@ class SpookInverseConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
hass: HomeAssistant,
options: Mapping[str, Any],
) -> None:
"""Hide or unhide the source entity as requested."""
hidden_by = (
er.RegistryEntryHider.INTEGRATION if options[CONF_HIDE_SOURCE] else None
)
_async_hide_source(hass, options[CONF_ENTITY_ID], hidden_by)
"""Hide the source entity if requested.
Showing it again is left to the update listener, which knows what the
options were before, and whether this inverse was the one hiding it.
"""
if options[CONF_HIDE_SOURCE]:
_async_hide_source(
hass, options[CONF_ENTITY_ID], er.RegistryEntryHider.INTEGRATION
)
def _async_hide_source(
hass: HomeAssistant,
source_entity_id: str,
hidden_by: er.RegistryEntryHider | None,
hidden_by: er.RegistryEntryHider,
) -> None:
"""Hide or unhide inverse source."""
"""Hide inverse source.
Never over somebody's own decision: a source they hid themselves in its
entity settings stays theirs.
"""
registry = er.async_get(hass)
if not (entity_id := er.async_resolve_entity_id(registry, source_entity_id)):
return
if entity_id not in registry.entities:
if (entity_entry := registry.async_get(entity_id)) is None:
return
if entity_entry.hidden_by == er.RegistryEntryHider.USER:
return
registry.async_update_entity(entity_id, hidden_by=hidden_by)
@@ -5,9 +5,20 @@ from homeassistant.const import Platform
DOMAIN = "spook_inverse"
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.COVER,
Platform.SWITCH,
Platform.VALVE,
]
# What each kind of inverse can be made of. An on/off helper or a light
# inverts as well as a switch does, and reads as well as a binary sensor.
SOURCE_DOMAINS: dict[str, list[str]] = {
Platform.BINARY_SENSOR: [Platform.BINARY_SENSOR, "input_boolean", Platform.LIGHT],
Platform.COVER: [Platform.COVER],
Platform.SWITCH: [Platform.SWITCH, "input_boolean", Platform.LIGHT],
Platform.VALVE: [Platform.VALVE],
}
CONF_HIDE_SOURCE = "hide_source"
CONF_INVERSE_POSTITION = "inverse_position"
CONF_INVERSE_POSITION = "inverse_position"
CONF_INVERSE_TILT = "inverse_tilt"
@@ -3,6 +3,7 @@
from __future__ import annotations
from abc import abstractmethod
from enum import IntFlag
from typing import TYPE_CHECKING
from homeassistant.const import (
@@ -13,7 +14,7 @@ from homeassistant.const import (
CONF_ENTITY_ID,
STATE_UNAVAILABLE,
)
from homeassistant.core import Event, HomeAssistant, State, callback
from homeassistant.core import Event, HomeAssistant, State, callback, split_entity_id
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import (
@@ -26,6 +27,24 @@ if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
def swapped_features[FeatureT: IntFlag](
features: FeatureT,
one: FeatureT,
other: FeatureT,
) -> FeatureT:
"""Return the features with these two traded places.
Something that can only be opened is, upside down, something that can
only be closed.
"""
swapped = features & ~(one | other)
if features & one:
swapped |= other
if features & other:
swapped |= one
return swapped
class InverseEntity(Entity): # pylint: disable=too-many-instance-attributes
"""Inverse entity."""
@@ -39,7 +58,13 @@ class InverseEntity(Entity): # pylint: disable=too-many-instance-attributes
) -> None:
"""Initialize an inverse entity."""
super().__init__()
self._entity_id = config_entry.options[CONF_ENTITY_ID]
# The source can be stored as its entity registry ID, which the state
# machine knows nothing about. Following that would leave the inverse
# unavailable next to a source that is perfectly fine.
source = config_entry.options[CONF_ENTITY_ID]
self._entity_id = (
er.async_resolve_entity_id(er.async_get(hass), source) or source
)
self._attr_name = config_entry.title
self._attr_extra_state_attributes = {ATTR_ENTITY_ID: self._entity_id}
self._attr_unique_id = config_entry.entry_id
@@ -87,14 +112,27 @@ class InverseEntity(Entity): # pylint: disable=too-many-instance-attributes
if (
state := self.hass.states.get(self._entity_id)
) is None or state.state == STATE_UNAVAILABLE:
# Written down as well, or the inverse goes on showing what it was
# before its source went, as if nothing happened.
self._attr_available = False
self.async_write_ha_state()
return
self._attr_available = True
self._attr_supported_features = state.attributes.get(ATTR_SUPPORTED_FEATURES)
self._attr_device_class = state.attributes.get(ATTR_DEVICE_CLASS)
self._attr_icon = state.attributes.get(ATTR_ICON)
# Only from a source of its own kind. A light's features are not a
# switch's, and a device class means something else for every domain:
# taken across, they claim things this entity cannot do or be.
if split_entity_id(self._entity_id)[0] == split_entity_id(self.entity_id)[0]:
self._attr_supported_features = state.attributes.get(
ATTR_SUPPORTED_FEATURES
)
self._attr_device_class = state.attributes.get(ATTR_DEVICE_CLASS)
else:
self._attr_supported_features = None
self._attr_device_class = None
self.async_update_state(state)
state_attributes = {
@@ -10,5 +10,5 @@
"iot_class": "calculated",
"issue_tracker": "https://github.com/frenck/spook/issues",
"requirements": [],
"version": "5.7.0"
"version": "5.8.1"
}
@@ -4,18 +4,16 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.components.switch import DOMAIN, SwitchEntity
from homeassistant.components.switch import SwitchEntity
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_ENTITY_ID,
SERVICE_TOGGLE,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_ON,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.core import HomeAssistant, State, callback, split_entity_id
from .entity import InverseEntity
@@ -30,15 +28,17 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Initialize inverse config entry."""
er.async_validate_entity_id(
er.async_get(hass),
config_entry.options[CONF_ENTITY_ID],
)
# The source is resolved by the entity itself, which stays unavailable
# rather than failing to set up when the source is not there.
async_add_entities([InverseSwitch(hass, config_entry)])
class InverseSwitch(InverseEntity, SwitchEntity):
"""Inverse switch."""
"""Inverse switch.
Its source can be an on/off helper or a light as well, so it is told what
to do in its own terms: a light is turned off with the light actions.
"""
@callback
def async_update_state(self, state: State) -> None:
@@ -51,7 +51,7 @@ class InverseSwitch(InverseEntity, SwitchEntity):
async def async_turn_on(self, **_: Any) -> None:
"""Turn the entity on."""
await self.hass.services.async_call(
DOMAIN,
split_entity_id(self._entity_id)[0],
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: self._entity_id},
blocking=True,
@@ -61,7 +61,7 @@ class InverseSwitch(InverseEntity, SwitchEntity):
async def async_turn_off(self, **_: Any) -> None:
"""Turn the entity off."""
await self.hass.services.async_call(
DOMAIN,
split_entity_id(self._entity_id)[0],
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: self._entity_id},
blocking=True,
@@ -71,7 +71,7 @@ class InverseSwitch(InverseEntity, SwitchEntity):
async def async_toggle(self, **_: Any) -> None:
"""Toggle the entity."""
await self.hass.services.async_call(
DOMAIN,
split_entity_id(self._entity_id)[0],
SERVICE_TOGGLE,
{ATTR_ENTITY_ID: self._entity_id},
blocking=True,
@@ -9,6 +9,16 @@
},
"title": "Inverse 👻"
},
"cover": {
"data": {
"entity_id": "Source entity",
"hide_source": "Hide source entity",
"inverse_position": "Inverse opening, closing and position",
"inverse_tilt": "Inverse tilt",
"name": "Name"
},
"title": "Inverse 👻"
},
"switch": {
"data": {
"entity_id": "Source entity",
@@ -17,11 +27,21 @@
},
"title": "Inverse 👻"
},
"valve": {
"data": {
"entity_id": "Source entity",
"hide_source": "Hide source entity",
"name": "Name"
},
"title": "Inverse 👻"
},
"user": {
"description": "This helper allow you to inverse the behavior of an entity. For example, reverse the open/close or on/off of binary sensors, and switches.",
"description": "This helper allows you to invert the behavior of an entity. For example, reverse the open/close or on/off of binary sensors, covers, switches, and valves.",
"menu_options": {
"binary_sensor": "Inverse a binary sensor",
"switch": "Inverse a switch"
"cover": "Inverse a cover",
"switch": "Inverse a switch",
"valve": "Inverse a valve"
},
"title": "Inverse 👻"
}
@@ -37,6 +57,16 @@
},
"title": "Inverse 👻"
},
"cover": {
"data": {
"entity_id": "Source entity",
"hide_source": "Hide source entity",
"inverse_position": "Inverse opening, closing and position",
"inverse_tilt": "Inverse tilt",
"name": "Name"
},
"title": "Inverse 👻"
},
"switch": {
"data": {
"entity_id": "Source entity",
@@ -44,6 +74,14 @@
"name": "Name"
},
"title": "Inverse 👻"
},
"valve": {
"data": {
"entity_id": "Source entity",
"hide_source": "Hide source entity",
"name": "Name"
},
"title": "Inverse 👻"
}
}
},
@@ -2,10 +2,12 @@
"config": {
"step": {
"user": {
"description": "Овај помоћник ти омогућава да преокренеш понашање ентитета. На пример, да обрнеш стања отворено/затворено или укључено/искључено за бинарне сензоре и прекидаче.",
"description": "Овај помоћник вам омогућава да обрнете понашање ентитета. На пример, преокрените стање отворено/затворено или укључено/искључено за бинарне сензоре, ролетне, прекидаче и вентиле.",
"menu_options": {
"binary_sensor": "Инвертуј бинарни сензор",
"switch": "Инвертуј прекидач"
"switch": "Инвертуј прекидач",
"cover": "Инвертуј застор",
"valve": "Инвертуј вентил"
},
"title": "Инвертуј 👻"
},
@@ -24,6 +26,24 @@
"name": "Назив"
},
"title": "Инвертуј 👻"
},
"cover": {
"data": {
"entity_id": "Изворни ентитет",
"hide_source": "Сакриј изворни ентитет",
"inverse_position": "Инверуј отварање, затварање и позицију",
"inverse_tilt": "Инверуј нагиб",
"name": "Назив"
},
"title": "Инверуј 👻"
},
"valve": {
"data": {
"entity_id": "Изворни ентитет",
"hide_source": "Сакриј изворни ентитет",
"name": "Назив"
},
"title": "Инверуј 👻"
}
}
},
@@ -44,8 +64,26 @@
"hide_source": "Сакриј изворни ентитет",
"name": "Назив"
}
},
"cover": {
"data": {
"entity_id": "Изворни ентитет",
"hide_source": "Сакриј изворни ентитет",
"inverse_position": "Инвертуј отварање, затварање и позицију",
"inverse_tilt": "Инвертуј нагиб",
"name": "Назив"
},
"title": "Инветуј 👻"
},
"valve": {
"data": {
"entity_id": "Изворни ентитет",
"hide_source": "Сакриј изворни ентитет",
"name": "Назив"
},
"title": "Инвертуј 👻"
}
}
},
"title": "Инвертуј 👻"
"title": "Инвертуј👻"
}
@@ -18,10 +18,30 @@
"title": "Invertuj 👻"
},
"user": {
"description": "Ovaj pomoćnik ti omogućava da preokreneš ponašanje entiteta. Na primer, da obrneš stanja otvoreno/zatvoreno ili uključeno/isključeno za binarne senzore i prekidače.",
"description": "Ovaj pomoćnik vam omogućava da obrnete ponašanje entiteta. Na primer, preokrenite stanje otvoreno/zatvoreno ili uključeno/isključeno za binarne senzore, roletne, prekidače i ventile.",
"menu_options": {
"binary_sensor": "Invertuj binarni senzor",
"switch": "Invertuj prekidač"
"switch": "Invertuj prekidač",
"cover": "Invertuj zastor",
"valve": "Invertuj ventil"
},
"title": "Invertuj 👻"
},
"cover": {
"data": {
"entity_id": "Izvorni entitet",
"hide_source": "Sakrij izvorni entitet",
"inverse_position": "Invertuj otvaranje, zatvaranje i poziciju",
"inverse_tilt": "Invertuj nagib",
"name": "Naziv"
},
"title": "Invertuj 👻"
},
"valve": {
"data": {
"entity_id": "Izvorni entitet",
"hide_source": "Sakrij izvorni entitet",
"name": "Naziv"
},
"title": "Invertuj 👻"
}
@@ -44,8 +64,26 @@
"hide_source": "Sakrij izvorni entitet"
},
"title": "Invertuj 👻"
},
"cover": {
"data": {
"hide_source": "Sakrij izvorni entitet",
"entity_id": "Izvorni entitet",
"inverse_position": "Invertuj otvaranje, zatvaranje i poziciju",
"inverse_tilt": "Inverutuj nagib",
"name": "Naziv"
},
"title": "Invertuj 👻"
},
"valve": {
"data": {
"hide_source": "Sakrij izvorni entitet",
"entity_id": "Izvorni entitet",
"name": "Naziv"
},
"title": "Invertuj 👻"
}
}
},
"title": "Invertuj 👻"
"title": "Invertuj👻"
}
@@ -0,0 +1,14 @@
{
"domain": "spook_time_in_state",
"name": "Time in state 👻",
"codeowners": [
"@frenck"
],
"config_flow": true,
"documentation": "https://spook.boo",
"integration_type": "helper",
"iot_class": "calculated",
"issue_tracker": "https://github.com/frenck/spook/issues",
"requirements": [],
"version": "5.8.1"
}

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