latest changes

This commit is contained in:
2025-11-20 20:09:50 +01:00
parent da963d8f78
commit 69252eb9c1
572 changed files with 6331 additions and 34469 deletions
+27 -16
View File
@@ -1,17 +1,20 @@
"""The better_thermostat component."""
import logging
from asyncio import Lock
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, Config
from homeassistant.core import HomeAssistant
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.typing import ConfigType
import voluptuous as vol
from .const import (
CONF_FIX_CALIBRATION,
from .utils.const import (
CONF_CALIBRATION_MODE,
CONF_HEATER,
CONF_NO_SYSTEM_MODE_OFF,
CONF_WINDOW_TIMEOUT,
CONF_WINDOW_TIMEOUT_AFTER,
CalibrationMode,
)
_LOGGER = logging.getLogger(__name__)
@@ -19,15 +22,20 @@ DOMAIN = "better_thermostat"
PLATFORMS = [Platform.CLIMATE]
CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA)
config_entry_update_listener_lock = Lock()
async def async_setup(hass: HomeAssistant, config: Config):
"""Set up this integration using YAML is not supported."""
hass.data[DOMAIN] = {}
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up this integration using YAML."""
if DOMAIN in config:
hass.data.setdefault(DOMAIN, {})
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data[DOMAIN] = {}
"""Set up entry."""
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = {}
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
return True
@@ -35,14 +43,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
await async_unload_entry(hass, entry)
await async_setup_entry(hass, entry)
async with config_entry_update_listener_lock:
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass, entry):
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
@@ -57,7 +66,7 @@ async def async_migrate_entry(hass, config_entry: ConfigEntry):
if config_entry.version == 1:
new = {**config_entry.data}
for trv in new[CONF_HEATER]:
trv["advanced"].update({CONF_FIX_CALIBRATION: False})
trv["advanced"].update({CalibrationMode.AGGRESIVE_CALIBRATION: False})
config_entry.version = 2
hass.config_entries.async_update_entry(config_entry, data=new)
@@ -71,12 +80,14 @@ async def async_migrate_entry(hass, config_entry: ConfigEntry):
new = {**config_entry.data}
for trv in new[CONF_HEATER]:
if (
CONF_FIX_CALIBRATION in trv["advanced"]
and trv["advanced"][CONF_FIX_CALIBRATION]
CalibrationMode.AGGRESIVE_CALIBRATION in trv["advanced"]
and trv["advanced"][CalibrationMode.AGGRESIVE_CALIBRATION]
):
trv["advanced"].update({CONF_CALIBRATION_MODE: CONF_FIX_CALIBRATION})
trv["advanced"].update(
{CONF_CALIBRATION_MODE: CalibrationMode.AGGRESIVE_CALIBRATION}
)
else:
trv["advanced"].update({CONF_CALIBRATION_MODE: "default"})
trv["advanced"].update({CONF_CALIBRATION_MODE: CalibrationMode.DEFAULT})
config_entry.version = 4
hass.config_entries.async_update_entry(config_entry, data=new)
@@ -34,8 +34,8 @@ async def get_current_offset(self, entity_id):
return float(str(self.hass.states.get(entity_id).attributes.get("offset", 0)))
async def get_offset_steps(self, entity_id):
"""Get offset steps."""
async def get_offset_step(self, entity_id):
"""Get offset step."""
return float(1.0)
@@ -21,14 +21,14 @@ async def get_info(self, entity_id):
async def init(self, entity_id):
if (
self.real_trvs[entity_id]["local_temperature_calibration_entity"] is None
and self.real_trvs[entity_id]["calibration"] == 0
and self.real_trvs[entity_id]["calibration"] != 1
):
self.real_trvs[entity_id][
"local_temperature_calibration_entity"
] = await find_local_calibration_entity(self, entity_id)
self.real_trvs[entity_id]["local_temperature_calibration_entity"] = (
await find_local_calibration_entity(self, entity_id)
)
_LOGGER.debug(
"better_thermostat %s: uses local calibration entity %s",
self.name,
self.device_name,
self.real_trvs[entity_id]["local_temperature_calibration_entity"],
)
# Wait for the entity to be available
@@ -39,7 +39,7 @@ async def init(self, entity_id):
).state in (STATE_UNAVAILABLE, STATE_UNKNOWN, None):
_LOGGER.info(
"better_thermostat %s: waiting for TRV/climate entity with id '%s' to become fully available...",
self.name,
self.device_name,
self.real_trvs[entity_id]["local_temperature_calibration_entity"],
)
await asyncio.sleep(5)
@@ -64,8 +64,8 @@ async def get_current_offset(self, entity_id):
return None
async def get_offset_steps(self, entity_id):
"""Get offset steps."""
async def get_offset_step(self, entity_id):
"""Get offset step."""
if self.real_trvs[entity_id]["local_temperature_calibration_entity"] is not None:
return float(
str(
@@ -119,13 +119,17 @@ async def set_temperature(self, entity_id, temperature):
async def set_hvac_mode(self, entity_id, hvac_mode):
"""Set new target hvac mode."""
await self.hass.services.async_call(
"climate",
"set_hvac_mode",
{"entity_id": entity_id, "hvac_mode": hvac_mode},
blocking=True,
context=self.context,
)
_LOGGER.debug("better_thermostat %s: set_hvac_mode %s", self.device_name, hvac_mode)
try:
await self.hass.services.async_call(
"climate",
"set_hvac_mode",
{"entity_id": entity_id, "hvac_mode": hvac_mode},
blocking=True,
context=self.context,
)
except TypeError:
_LOGGER.debug("TypeError in set_hvac_mode")
async def set_offset(self, entity_id, offset):
@@ -29,14 +29,14 @@ async def get_info(self, entity_id):
async def init(self, entity_id):
if (
self.real_trvs[entity_id]["local_temperature_calibration_entity"] is None
and self.real_trvs[entity_id]["calibration"] == 0
and self.real_trvs[entity_id]["calibration"] != 1
):
self.real_trvs[entity_id][
"local_temperature_calibration_entity"
] = await find_local_calibration_entity(self, entity_id)
self.real_trvs[entity_id]["local_temperature_calibration_entity"] = (
await find_local_calibration_entity(self, entity_id)
)
_LOGGER.debug(
"better_thermostat %s: uses local calibration entity %s",
self.name,
self.device_name,
self.real_trvs[entity_id]["local_temperature_calibration_entity"],
)
# Wait for the entity to be available
@@ -47,7 +47,7 @@ async def init(self, entity_id):
).state in (STATE_UNAVAILABLE, STATE_UNKNOWN, None):
_LOGGER.info(
"better_thermostat %s: waiting for TRV/climate entity with id '%s' to become fully available...",
self.name,
self.device_name,
self.real_trvs[entity_id]["local_temperature_calibration_entity"],
)
await asyncio.sleep(5)
@@ -90,8 +90,8 @@ async def get_current_offset(self, entity_id):
)
async def get_offset_steps(self, entity_id):
"""Get offset steps."""
async def get_offset_step(self, entity_id):
"""Get offset step."""
return float(
str(
self.hass.states.get(
@@ -159,7 +159,7 @@ async def set_offset(self, entity_id, offset):
async def set_valve(self, entity_id, valve):
"""Set new target valve."""
_LOGGER.debug(
f"better_thermostat {self.name}: TO TRV {entity_id} set_valve: {valve}"
f"better_thermostat {self.device_name}: TO TRV {entity_id} set_valve: {valve}"
)
await self.hass.services.async_call(
"number",
@@ -33,8 +33,8 @@ async def get_current_offset(self, entity_id):
)
async def get_offset_steps(self, entity_id):
"""Get offset steps."""
async def get_offset_step(self, entity_id):
"""Get offset step."""
return float(0.01)
File diff suppressed because it is too large Load Diff
@@ -10,18 +10,18 @@ from homeassistant.components.climate.const import HVACMode
from homeassistant.helpers import config_validation as cv
from .utils.bridge import load_adapter
from .adapters.delegate import load_adapter
from .utils.helpers import get_device_model, get_trv_intigration
from .const import (
from .utils.const import (
CONF_COOLER,
CONF_PROTECT_OVERHEATING,
CONF_CALIBRATION,
CONF_CHILD_LOCK,
CONF_HEAT_AUTO_SWAPPED,
CONF_HEATER,
CONF_HOMATICIP,
CONF_HOMEMATICIP,
CONF_HUMIDITY,
CONF_MODEL,
CONF_NO_SYSTEM_MODE_OFF,
@@ -35,6 +35,7 @@ from .const import (
CONF_WINDOW_TIMEOUT_AFTER,
CONF_CALIBRATION_MODE,
CONF_TOLERANCE,
CONF_TARGET_TEMP_STEP,
CalibrationMode,
CalibrationType,
)
@@ -65,6 +66,21 @@ CALIBRATION_TYPE_ALL_SELECTOR = selector.SelectSelector(
selector.SelectOptionDict(
value=CalibrationType.LOCAL_BASED, label="Offset Based"
),
selector.SelectOptionDict(value=CalibrationType.HYBRID, label="Hybrid"),
],
mode=selector.SelectSelectorMode.DROPDOWN,
)
)
TEMP_STEP_SELECTOR = selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(value="0.0", label="Auto"),
selector.SelectOptionDict(value="0.1", label="0.1 °C"),
selector.SelectOptionDict(value="0.2", label="0.2 °C"),
selector.SelectOptionDict(value="0.25", label="0.25 °C"),
selector.SelectOptionDict(value="0.5", label="0.5 °C"),
selector.SelectOptionDict(value="1.0", label="1 °C"),
],
mode=selector.SelectSelectorMode.DROPDOWN,
)
@@ -75,7 +91,7 @@ CALIBRATION_MODE_SELECTOR = selector.SelectSelector(
options=[
selector.SelectOptionDict(value=CalibrationMode.DEFAULT, label="Normal"),
selector.SelectOptionDict(
value=CalibrationMode.FIX_CALIBRATION, label="Agressive"
value=CalibrationMode.AGGRESIVE_CALIBRATION, label="Agressive"
),
selector.SelectOptionDict(
value=CalibrationMode.HEATING_POWER_CALIBRATION, label="AI Time Based"
@@ -95,13 +111,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
def __init__(self):
"""Initialize the config flow."""
self.name = ""
self.device_name = ""
self.data = None
self.model = None
self.heater_entity_id = None
self.trv_bundle = []
self.integration = None
self.i = 0
super().__init__()
@staticmethod
@callback
@@ -119,7 +136,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if user_input is not None:
if self.data is not None:
_LOGGER.debug("Confirm: %s", self.data[CONF_HEATER])
await self.async_set_unique_id(self.data["name"])
unique_trv_string = "_".join([x["trv"] for x in self.data[CONF_HEATER]])
await self.async_set_unique_id(
f"{self.data['name']}_{unique_trv_string}"
)
self._abort_if_unique_id_configured()
return self.async_create_entry(title=self.data["name"], data=self.data)
if confirm_type is not None:
errors["base"] = confirm_type
@@ -226,7 +247,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
] = bool
fields[
vol.Optional(
CONF_HOMATICIP, default=user_input.get(CONF_HOMATICIP, homematic)
CONF_HOMEMATICIP, default=user_input.get(CONF_HOMEMATICIP, homematic)
)
] = bool
@@ -289,7 +310,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"trv": trv,
"integration": _intigration,
"model": await get_device_model(self, trv),
"adapter": load_adapter(self, _intigration, trv),
"adapter": await load_adapter(self, _intigration, trv),
}
)
self.data[CONF_MODEL] = "/".join([x["model"] for x in self.trv_bundle])
@@ -354,6 +375,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
vol.Optional(
CONF_TOLERANCE, default=user_input.get(CONF_TOLERANCE, 0.0)
): vol.All(vol.Coerce(float), vol.Range(min=0)),
vol.Optional(
CONF_TARGET_TEMP_STEP,
default=str(user_input.get(CONF_TARGET_TEMP_STEP, "0.0")),
): TEMP_STEP_SELECTOR,
}
),
errors=errors,
@@ -366,13 +391,12 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
"""Initialize options flow."""
self.config_entry = config_entry
self.options = dict(config_entry.options)
self.i = 0
self.trv_bundle = []
self.name = ""
self.device_name = ""
self._last_step = False
self.updated_config = {}
super().__init__()
async def async_step_init(self, user_input=None):
"""Manage the options."""
@@ -412,9 +436,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
fields = OrderedDict()
_default_calibration = "target_temp_based"
self.name = user_input.get(CONF_NAME, "-")
self.device_name = user_input.get(CONF_NAME, "-")
_adapter = load_adapter(
_adapter = await load_adapter(
self, _trv_config.get("integration"), _trv_config.get("trv")
)
if _adapter is not None:
@@ -499,8 +523,8 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
] = bool
fields[
vol.Optional(
CONF_HOMATICIP,
default=_trv_config["advanced"].get(CONF_HOMATICIP, homematic),
CONF_HOMEMATICIP,
default=_trv_config["advanced"].get(CONF_HOMEMATICIP, homematic),
)
] = bool
@@ -556,6 +580,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
self.updated_config[CONF_TOLERANCE] = float(
user_input.get(CONF_TOLERANCE, 0.0)
)
self.updated_config[CONF_TARGET_TEMP_STEP] = float(
user_input.get(CONF_TARGET_TEMP_STEP, "0.0")
)
for trv in self.updated_config[CONF_HEATER]:
trv["adapter"] = None
@@ -683,6 +710,12 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
CONF_TOLERANCE, default=self.config_entry.data.get(CONF_TOLERANCE, 0.0)
)
] = vol.All(vol.Coerce(float), vol.Range(min=0))
fields[
vol.Optional(
CONF_TARGET_TEMP_STEP,
default=str(self.config_entry.data.get(CONF_TARGET_TEMP_STEP, 0.0)),
)
] = TEMP_STEP_SELECTOR
return self.async_show_form(
step_id="user", data_schema=vol.Schema(fields), last_step=False
@@ -1,102 +0,0 @@
""""""
import json
from enum import IntEnum
from homeassistant.backports.enum import StrEnum
import logging
import voluptuous as vol
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.config_validation import ( # noqa: F401
make_entity_service_schema,
)
from homeassistant.components.climate.const import SUPPORT_TARGET_TEMPERATURE
from homeassistant.const import ATTR_TEMPERATURE
_LOGGER = logging.getLogger(__name__)
DEFAULT_NAME = "Better Thermostat"
VERSION = "master"
try:
with open("custom_components/better_thermostat/manifest.json") as manifest_file:
manifest = json.load(manifest_file)
VERSION = manifest["version"]
except (FileNotFoundError, KeyError, json.JSONDecodeError) as e:
_LOGGER.error("better_thermostat %s: could not read version from manifest file.", e)
CONF_HEATER = "thermostat"
CONF_COOLER = "cooler"
CONF_SENSOR = "temperature_sensor"
CONF_HUMIDITY = "humidity_sensor"
CONF_SENSOR_WINDOW = "window_sensors"
CONF_TARGET_TEMP = "target_temp"
CONF_WEATHER = "weather"
CONF_OFF_TEMPERATURE = "off_temperature"
CONF_WINDOW_TIMEOUT = "window_off_delay"
CONF_WINDOW_TIMEOUT_AFTER = "window_off_delay_after"
CONF_OUTDOOR_SENSOR = "outdoor_sensor"
CONF_VALVE_MAINTENANCE = "valve_maintenance"
CONF_MIN_TEMP = "min_temp"
CONF_MAX_TEMP = "max_temp"
CONF_PRECISION = "precision"
CONF_CALIBRATION = "calibration"
CONF_CHILD_LOCK = "child_lock"
CONF_PROTECT_OVERHEATING = "protect_overheating"
CONF_CALIBRATION_MODE = "calibration_mode"
CONF_FIX_CALIBRATION = "fix_calibration"
CONF_HEATING_POWER_CALIBRATION = "heating_power_calibration"
CONF_NO_CALIBRATION = "no_calibration"
CONF_HEAT_AUTO_SWAPPED = "heat_auto_swapped"
CONF_MODEL = "model"
CONF_HOMATICIP = "homaticip"
CONF_INTEGRATION = "integration"
CONF_NO_SYSTEM_MODE_OFF = "no_off_system_mode"
CONF_TOLERANCE = "tolerance"
SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE
ATTR_STATE_WINDOW_OPEN = "window_open"
ATTR_STATE_CALL_FOR_HEAT = "call_for_heat"
ATTR_STATE_LAST_CHANGE = "last_change"
ATTR_STATE_SAVED_TEMPERATURE = "saved_temperature"
ATTR_VALVE_POSITION = "valve_position"
ATTR_STATE_HUMIDIY = "humidity"
ATTR_STATE_MAIN_MODE = "main_mode"
ATTR_STATE_HEATING_POWER = "heating_power"
ATTR_STATE_HEATING_STATS = "heating_stats"
ATTR_STATE_ERRORS = "errors"
ATTR_STATE_BATTERIES = "batteries"
SERVICE_RESTORE_SAVED_TARGET_TEMPERATURE = "restore_saved_target_temperature"
SERVICE_SET_TEMP_TARGET_TEMPERATURE = "set_temp_target_temperature"
SERVICE_RESET_HEATING_POWER = "reset_heating_power"
BETTERTHERMOSTAT_SET_TEMPERATURE_SCHEMA = vol.All(
cv.has_at_least_one_key(ATTR_TEMPERATURE),
make_entity_service_schema(
{vol.Exclusive(ATTR_TEMPERATURE, "temperature"): vol.Coerce(float)}
),
)
class BetterThermostatEntityFeature(IntEnum):
"""Supported features of the climate entity."""
TARGET_TEMPERATURE = 1
TARGET_TEMPERATURE_RANGE = 2
class CalibrationType(StrEnum):
"""Calibration type"""
TARGET_TEMP_BASED = "target_temp_based"
LOCAL_BASED = "local_calibration_based"
class CalibrationMode(StrEnum):
"""Calibration mode."""
DEFAULT = "default"
FIX_CALIBRATION = "fix_calibration"
HEATING_POWER_CALIBRATION = "heating_power_calibration"
NO_CALIBRATION = "no_calibration"
@@ -11,11 +11,7 @@ from homeassistant.components.homeassistant.triggers import (
from . import DOMAIN
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import ConfigType
from homeassistant.components.climate.const import (
ATTR_CURRENT_TEMPERATURE,
ATTR_CURRENT_HUMIDITY,
HVAC_MODES,
)
from homeassistant.components.climate.const import HVAC_MODES
from homeassistant.const import (
CONF_ABOVE,
CONF_BELOW,
@@ -32,7 +28,7 @@ from homeassistant.const import (
async def async_get_triggers(
hass: HomeAssistant, device_id: str
) -> list[dict[str, str]]:
"""List device triggers for Climate devices."""
"""List device triggers for Better Thermostat devices."""
registry = entity_registry.async_get(hass)
triggers = []
@@ -42,8 +38,9 @@ async def async_get_triggers(
continue
state = hass.states.get(entry.entity_id)
if not state:
continue
# Add triggers for each entity that belongs to this integration
base_trigger = {
CONF_PLATFORM: "device",
CONF_DEVICE_ID: device_id,
@@ -51,13 +48,26 @@ async def async_get_triggers(
CONF_ENTITY_ID: entry.entity_id,
}
triggers.append({**base_trigger, CONF_TYPE: "hvac_mode_changed"})
if state and ATTR_CURRENT_TEMPERATURE in state.attributes:
triggers.append({**base_trigger, CONF_TYPE: "current_temperature_changed"})
if state and ATTR_CURRENT_HUMIDITY in state.attributes:
triggers.append({**base_trigger, CONF_TYPE: "current_humidity_changed"})
# Add standard climate triggers
triggers.extend(
[
{
**base_trigger,
CONF_TYPE: "hvac_mode_changed",
"metadata": {"secondary": False},
},
{
**base_trigger,
CONF_TYPE: "current_temperature_changed",
"metadata": {"secondary": False},
},
{
**base_trigger,
CONF_TYPE: "current_humidity_changed",
"metadata": {"secondary": True},
},
]
)
return triggers
@@ -93,13 +103,13 @@ async def async_attach_trigger(
}
if trigger_type == "current_temperature_changed":
numeric_state_config[
numeric_state_trigger.CONF_VALUE_TEMPLATE
] = "{{ state.attributes.current_temperature }}"
numeric_state_config[numeric_state_trigger.CONF_VALUE_TEMPLATE] = (
"{{ state.attributes.current_temperature }}"
)
else:
numeric_state_config[
numeric_state_trigger.CONF_VALUE_TEMPLATE
] = "{{ state.attributes.current_humidity }}"
numeric_state_config[numeric_state_trigger.CONF_VALUE_TEMPLATE] = (
"{{ state.attributes.current_humidity }}"
)
if CONF_ABOVE in config:
numeric_state_config[CONF_ABOVE] = config[CONF_ABOVE]
@@ -122,31 +132,35 @@ async def async_get_trigger_capabilities(
"""List trigger capabilities."""
trigger_type = config[CONF_TYPE]
if trigger_type == "hvac_action_changed":
return {}
if trigger_type == "hvac_mode_changed":
return {
"extra_fields": vol.Schema(
{vol.Optional(CONF_FOR): cv.positive_time_period_dict}
{
vol.Required(state_trigger.CONF_TO): vol.In(HVAC_MODES),
vol.Optional(CONF_FOR): cv.positive_time_period_dict,
}
)
}
if trigger_type == "current_temperature_changed":
unit_of_measurement = hass.config.units.temperature_unit
else:
unit_of_measurement = PERCENTAGE
return {
"extra_fields": vol.Schema(
{
vol.Optional(
CONF_ABOVE, description={"suffix": unit_of_measurement}
): vol.Coerce(float),
vol.Optional(
CONF_BELOW, description={"suffix": unit_of_measurement}
): vol.Coerce(float),
vol.Optional(CONF_FOR): cv.positive_time_period_dict,
}
# Temperature and humidity triggers use the same schema
if trigger_type in ["current_temperature_changed", "current_humidity_changed"]:
unit = (
hass.config.units.temperature_unit
if trigger_type == "current_temperature_changed"
else PERCENTAGE
)
}
return {
"extra_fields": vol.Schema(
{
vol.Optional(CONF_ABOVE, description={"suffix": unit}): vol.Coerce(
float
),
vol.Optional(CONF_BELOW, description={"suffix": unit}): vol.Coerce(
float
),
vol.Optional(CONF_FOR): cv.positive_time_period_dict,
}
)
}
return {}
@@ -1,12 +1,11 @@
"""Diagnostics support for Brother."""
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .utils.bridge import load_adapter
from .const import CONF_HEATER, CONF_SENSOR, CONF_SENSOR_WINDOW
from .utils.const import CONF_HEATER, CONF_SENSOR, CONF_SENSOR_WINDOW
async def async_get_config_entry_diagnostics(
@@ -18,8 +17,9 @@ async def async_get_config_entry_diagnostics(
trv = hass.states.get(trv_id["trv"])
if trv is None:
continue
_adapter_name = load_adapter(hass, trv_id["integration"], trv_id["trv"], True)
trv_id["adapter"] = _adapter_name
trv_id["adapter"] = trv_id["integration"]
if trv_id["adapter"] is None:
trv_id["adapter"] = "unknown"
trvs[trv_id["trv"]] = {
"name": trv.name,
"state": trv.state,
@@ -29,13 +29,13 @@ async def trigger_cooler_change(self, event):
if None in (new_state, old_state, new_state.attributes):
_LOGGER.debug(
f"better_thermostat {self.name}: Cooler {entity_id} update contained not all necessary data for processing, skipping"
f"better_thermostat {self.device_name}: Cooler {entity_id} update contained not all necessary data for processing, skipping"
)
return
if not isinstance(new_state, State) or not isinstance(old_state, State):
_LOGGER.debug(
f"better_thermostat {self.name}: Cooler {entity_id} update contained not a State, skipping"
f"better_thermostat {self.device_name}: Cooler {entity_id} update contained not a State, skipping"
)
return
# set context HACK TO FIND OUT IF AN EVENT WAS SEND BY BT
@@ -44,7 +44,9 @@ async def trigger_cooler_change(self, event):
if self.context == event.context:
return
_LOGGER.debug(f"better_thermostat {self.name}: Cooler {entity_id} update received")
_LOGGER.debug(
f"better_thermostat {self.device_name}: Cooler {entity_id} update received"
)
_main_key = "temperature"
if "temperature" not in old_state.attributes:
@@ -52,12 +54,12 @@ async def trigger_cooler_change(self, event):
_old_cooling_setpoint = convert_to_float(
str(old_state.attributes.get(_main_key, None)),
self.name,
self.device_name,
"trigger_cooler_change()",
)
_new_cooling_setpoint = convert_to_float(
str(new_state.attributes.get(_main_key, None)),
self.name,
self.device_name,
"trigger_cooler_change()",
)
if (
@@ -66,14 +68,14 @@ async def trigger_cooler_change(self, event):
and self.bt_hvac_mode is not HVACMode.OFF
):
_LOGGER.debug(
f"better_thermostat {self.name}: trigger_cooler_change / _old_cooling_setpoint: {_old_cooling_setpoint} - _new_cooling_setpoint: {_new_cooling_setpoint}"
f"better_thermostat {self.device_name}: trigger_cooler_change / _old_cooling_setpoint: {_old_cooling_setpoint} - _new_cooling_setpoint: {_new_cooling_setpoint}"
)
if (
_new_cooling_setpoint < self.bt_min_temp
or self.bt_max_temp < _new_cooling_setpoint
):
_LOGGER.warning(
f"better_thermostat {self.name}: New Cooler {entity_id} setpoint outside of range, overwriting it"
f"better_thermostat {self.device_name}: New Cooler {entity_id} setpoint outside of range, overwriting it"
)
if _new_cooling_setpoint < self.bt_min_temp:
@@ -1,8 +1,9 @@
import logging
from custom_components.better_thermostat.const import CONF_HOMATICIP
from custom_components.better_thermostat.utils.const import CONF_HOMEMATICIP
from ..utils.helpers import convert_to_float
from datetime import datetime
from homeassistant.helpers import issue_registry as ir
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import callback
@@ -33,18 +34,36 @@ async def trigger_temperature_change(self, event):
return
_incoming_temperature = convert_to_float(
str(new_state.state), self.name, "external_temperature"
str(new_state.state), self.device_name, "external_temperature"
)
_time_diff = 5
_time_diff = 60
try:
for trv in self.all_trvs:
if trv["advanced"][CONF_HOMATICIP]:
if trv["advanced"][CONF_HOMEMATICIP]:
_time_diff = 600
except KeyError:
pass
if _incoming_temperature is None or _incoming_temperature < -50:
# raise a ha repair notication
_LOGGER.error(
"better_thermostat %s: external_temperature is not a valid number: %s",
self.device_name,
new_state.state,
)
ir.async_create_issue(
hass=self.hass,
issue_id=f"missing_entity_{self.device_name}",
issue_title=f"better_thermostat {self.device_name} has invalid external_temperature value",
issue_severity="error",
issue_description=f"better_thermostat {self.device_name} has invalid external_temperature: {new_state.state}",
issue_category="config",
issue_suggested_action="Please check the external_temperature sensor",
)
return
if (
_incoming_temperature != self.cur_temp
and (datetime.now() - self.last_external_sensor_change).total_seconds()
@@ -52,7 +71,7 @@ async def trigger_temperature_change(self, event):
):
_LOGGER.debug(
"better_thermostat %s: external_temperature changed from %s to %s",
self.name,
self.device_name,
self.cur_temp,
_incoming_temperature,
)
@@ -1,8 +1,7 @@
import asyncio
from datetime import datetime
import logging
from typing import Union
from custom_components.better_thermostat.const import CONF_HOMATICIP
from custom_components.better_thermostat.utils.const import CONF_HOMEMATICIP
from homeassistant.components.climate.const import (
HVACMode,
@@ -11,14 +10,21 @@ from homeassistant.components.climate.const import (
)
from homeassistant.core import State, callback
from homeassistant.components.group.util import find_state_attributes
from ..utils.helpers import (
calculate_local_setpoint_delta,
calculate_setpoint_override,
from custom_components.better_thermostat.utils.helpers import (
convert_to_float,
mode_remap,
round_to_half_degree,
)
from custom_components.better_thermostat.utils.bridge import get_current_offset
from custom_components.better_thermostat.adapters.delegate import get_current_offset
from custom_components.better_thermostat.utils.const import (
CalibrationType,
CalibrationMode,
)
from custom_components.better_thermostat.calibration import (
calculate_calibration_local,
calculate_calibration_setpoint,
)
_LOGGER = logging.getLogger(__name__)
@@ -30,6 +36,8 @@ async def trigger_trv_change(self, event):
return
if self.control_queue_task is None:
return
if self.bt_target_temp is None or self.cur_temp is None or self.tolerance is None:
return
asyncio.create_task(update_hvac_action(self))
_main_change = False
old_state = event.data.get("old_state")
@@ -38,13 +46,13 @@ async def trigger_trv_change(self, event):
if None in (new_state, old_state, new_state.attributes):
_LOGGER.debug(
f"better_thermostat {self.name}: TRV {entity_id} update contained not all necessary data for processing, skipping"
f"better_thermostat {self.device_name}: TRV {entity_id} update contained not all necessary data for processing, skipping"
)
return
if not isinstance(new_state, State) or not isinstance(old_state, State):
_LOGGER.debug(
f"better_thermostat {self.name}: TRV {entity_id} update contained not a State, skipping"
f"better_thermostat {self.device_name}: TRV {entity_id} update contained not a State, skipping"
)
return
# set context HACK TO FIND OUT IF AN EVENT WAS SEND BY BT
@@ -53,21 +61,21 @@ async def trigger_trv_change(self, event):
if self.context == event.context:
return
_LOGGER.debug(f"better_thermostat {self.name}: TRV {entity_id} update received")
# _LOGGER.debug(f"better_thermostat {self.device_name}: TRV {entity_id} update received")
_org_trv_state = self.hass.states.get(entity_id)
child_lock = self.real_trvs[entity_id]["advanced"].get("child_lock")
_new_current_temp = convert_to_float(
str(_org_trv_state.attributes.get("current_temperature", None)),
self.name,
self.device_name,
"TRV_current_temp",
)
_time_diff = 5
try:
for trv in self.all_trvs:
if trv["advanced"][CONF_HOMATICIP]:
if trv["advanced"][CONF_HOMEMATICIP]:
_time_diff = 600
except KeyError:
pass
@@ -79,14 +87,14 @@ async def trigger_trv_change(self, event):
> _time_diff
or (
self.real_trvs[entity_id]["calibration_received"] is False
and self.real_trvs[entity_id]["calibration"] == 0
and self.real_trvs[entity_id]["calibration"] != 1
)
)
):
_old_temp = self.real_trvs[entity_id]["current_temperature"]
self.real_trvs[entity_id]["current_temperature"] = _new_current_temp
_LOGGER.debug(
f"better_thermostat {self.name}: TRV {entity_id} sends new internal temperature from {_old_temp} to {_new_current_temp}"
f"better_thermostat {self.device_name}: TRV {entity_id} sends new internal temperature from {_old_temp} to {_new_current_temp}"
)
self.last_internal_sensor_change = datetime.now()
_main_change = True
@@ -95,15 +103,13 @@ async def trigger_trv_change(self, event):
if self.real_trvs[entity_id]["calibration_received"] is False:
self.real_trvs[entity_id]["calibration_received"] = True
_LOGGER.debug(
f"better_thermostat {self.name}: calibration accepted by TRV {entity_id}"
f"better_thermostat {self.device_name}: calibration accepted by TRV {entity_id}"
)
_main_change = False
self.old_internal_temp = self.real_trvs[entity_id]["current_temperature"]
self.old_external_temp = self.cur_temp
if self.real_trvs[entity_id]["calibration"] == 0:
self.real_trvs[entity_id][
"last_calibration"
] = await get_current_offset(self, entity_id)
self.real_trvs[entity_id]["last_calibration"] = (
await get_current_offset(self, entity_id)
)
if self.ignore_states:
return
@@ -112,7 +118,7 @@ async def trigger_trv_change(self, event):
mapped_state = convert_inbound_states(self, entity_id, _org_trv_state)
except TypeError:
_LOGGER.debug(
f"better_thermostat {self.name}: remapping TRV {entity_id} state failed, skipping"
f"better_thermostat {self.device_name}: remapping TRV {entity_id} state failed, skipping"
)
return
@@ -123,7 +129,7 @@ async def trigger_trv_change(self, event):
):
_old = self.real_trvs[entity_id]["hvac_mode"]
_LOGGER.debug(
f"better_thermostat {self.name}: TRV {entity_id} decoded TRV mode changed from {_old} to {_org_trv_state.state} - converted {new_state.state}"
f"better_thermostat {self.device_name}: TRV {entity_id} decoded TRV mode changed from {_old} to {_org_trv_state.state} - converted {new_state.state}"
)
self.real_trvs[entity_id]["hvac_mode"] = _org_trv_state.state
_main_change = True
@@ -136,16 +142,16 @@ async def trigger_trv_change(self, event):
_main_key = "temperature"
if "temperature" not in old_state.attributes:
_main_key = "target_temp_high"
_main_key = "target_temp_low"
_old_heating_setpoint = convert_to_float(
str(old_state.attributes.get(_main_key, None)),
self.name,
self.device_name,
"trigger_trv_change()",
)
_new_heating_setpoint = convert_to_float(
str(new_state.attributes.get(_main_key, None)),
self.name,
self.device_name,
"trigger_trv_change()",
)
if (
@@ -154,14 +160,14 @@ async def trigger_trv_change(self, event):
and self.bt_hvac_mode is not HVACMode.OFF
):
_LOGGER.debug(
f"better_thermostat {self.name}: trigger_trv_change / _old_heating_setpoint: {_old_heating_setpoint} - _new_heating_setpoint: {_new_heating_setpoint} - _last_temperature: {self.real_trvs[entity_id]['last_temperature']}"
f"better_thermostat {self.device_name}: trigger_trv_change test / _old_heating_setpoint: {_old_heating_setpoint} - _new_heating_setpoint: {_new_heating_setpoint} - _last_temperature: {self.real_trvs[entity_id]['last_temperature']}"
)
if (
_new_heating_setpoint < self.bt_min_temp
or self.bt_max_temp < _new_heating_setpoint
):
_LOGGER.warning(
f"better_thermostat {self.name}: New TRV {entity_id} setpoint outside of range, overwriting it"
f"better_thermostat {self.device_name}: New TRV {entity_id} setpoint outside of range, overwriting it"
)
if _new_heating_setpoint < self.bt_min_temp:
@@ -180,7 +186,7 @@ async def trigger_trv_change(self, event):
and self.window_open is False
):
_LOGGER.debug(
f"better_thermostat {self.name}: TRV {entity_id} decoded TRV target temp changed from {self.bt_target_temp} to {_new_heating_setpoint}"
f"better_thermostat {self.device_name}: TRV {entity_id} decoded TRV target temp changed from {self.bt_target_temp} to {_new_heating_setpoint}"
)
self.bt_target_temp = _new_heating_setpoint
if self.cooler_entity_id is not None:
@@ -210,6 +216,9 @@ async def trigger_trv_change(self, event):
async def update_hvac_action(self):
self.bt_target_temp = 5.0 if self.bt_target_temp is None else self.bt_target_temp
self.cur_temp = 5.0 if self.cur_temp is None else self.cur_temp
self.tolerance = 0.0 if self.tolerance is None else self.tolerance
"""Update the hvac action."""
if self.startup_running or self.control_queue_task is None:
return
@@ -218,7 +227,7 @@ async def update_hvac_action(self):
# i don't know why this is here just for hometicip / wtom - 2023-08-23
# for trv in self.all_trvs:
# if trv["advanced"][CONF_HOMATICIP]:
# if trv["advanced"][CONF_HOMEMATICIP]:
# entity_id = trv["trv"]
# state = self.hass.states.get(entity_id)
# if state is None:
@@ -290,7 +299,7 @@ def convert_inbound_states(self, entity_id, state: State) -> str:
return remapped_state
def convert_outbound_states(self, entity_id, hvac_mode) -> Union[dict, None]:
def convert_outbound_states(self, entity_id, hvac_mode) -> dict | None:
"""Creates the new outbound thermostat state.
Parameters
----------
@@ -314,90 +323,60 @@ def convert_outbound_states(self, entity_id, hvac_mode) -> Union[dict, None]:
_new_heating_setpoint = None
try:
_calibration_type = self.real_trvs[entity_id].get("calibration", 1)
_calibration_type = self.real_trvs[entity_id]["advanced"].get("calibration")
_calibration_mode = self.real_trvs[entity_id]["advanced"].get(
"calibration_mode"
)
if _calibration_type is None:
_LOGGER.warning(
"better_thermostat %s: no calibration type found in device config, talking to the TRV using fallback mode",
self.name,
self.device_name,
)
_new_heating_setpoint = self.bt_target_temp
_new_local_calibration = round_to_half_degree(
calculate_local_setpoint_delta(self, entity_id)
)
_new_local_calibration = calculate_calibration_local(self, entity_id)
if _new_local_calibration is None:
return None
else:
if _calibration_type == 0:
_round_calibration = self.real_trvs[entity_id]["advanced"].get(
"calibration_round"
)
if _round_calibration is not None and (
(
isinstance(_round_calibration, str)
and _round_calibration.lower() == "true"
)
or _round_calibration is True
):
_new_local_calibration = round_to_half_degree(
calculate_local_setpoint_delta(self, entity_id)
)
else:
_new_local_calibration = calculate_local_setpoint_delta(
self, entity_id
)
if _calibration_type == CalibrationType.LOCAL_BASED:
_new_local_calibration = calculate_calibration_local(self, entity_id)
_new_heating_setpoint = self.bt_target_temp
elif _calibration_type == 1:
_round_calibration = self.real_trvs[entity_id]["advanced"].get(
"calibration_round"
)
if _round_calibration is not None and (
(
isinstance(_round_calibration, str)
and _round_calibration.lower() == "true"
)
or _round_calibration is True
):
_new_heating_setpoint = round_to_half_degree(
calculate_setpoint_override(self, entity_id)
)
elif _calibration_type == CalibrationType.TARGET_TEMP_BASED:
if _calibration_mode == CalibrationMode.NO_CALIBRATION:
_new_heating_setpoint = self.bt_target_temp
else:
_new_heating_setpoint = calculate_setpoint_override(self, entity_id)
_new_heating_setpoint = calculate_calibration_setpoint(
self, entity_id
)
_system_modes = self.real_trvs[entity_id]["hvac_modes"]
_has_system_mode = False
if _system_modes is not None:
_has_system_mode = True
_has_system_mode = _system_modes is not None
# Handling different devices with or without system mode reported or contained in the device config
hvac_mode = mode_remap(self, entity_id, str(hvac_mode), False)
if _has_system_mode is False:
if not _has_system_mode:
_LOGGER.debug(
f"better_thermostat {self.name}: device config expects no system mode, while the device has one. Device system mode will be ignored"
f"better_thermostat {self.device_name}: device config expects no system mode, while the device has one. Device system mode will be ignored"
)
if hvac_mode == HVACMode.OFF:
_new_heating_setpoint = self.real_trvs[entity_id]["min_temp"]
hvac_mode = None
if (
if hvac_mode == HVACMode.OFF and (
HVACMode.OFF not in _system_modes
or self.real_trvs[entity_id]["advanced"].get(
"no_off_system_mode", False
)
is True
or self.real_trvs[entity_id]["advanced"].get("no_off_system_mode")
):
if hvac_mode == HVACMode.OFF:
_LOGGER.debug(
f"better_thermostat {self.name}: sending 5°C to the TRV because this device has no system mode off and heater should be off"
)
_new_heating_setpoint = self.real_trvs[entity_id]["min_temp"]
hvac_mode = None
_min_temp = self.real_trvs[entity_id]["min_temp"]
_LOGGER.debug(
f"better_thermostat {self.device_name}: sending {_min_temp}°C to the TRV because this device has no system mode off and heater should be off"
)
_new_heating_setpoint = _min_temp
hvac_mode = None
return {
"temperature": _new_heating_setpoint,
@@ -1,8 +1,10 @@
import asyncio
import logging
from custom_components.better_thermostat import DOMAIN
from homeassistant.core import callback
from homeassistant.const import STATE_OFF
from homeassistant.helpers import issue_registry as ir
_LOGGER = logging.getLogger(__name__)
@@ -37,7 +39,7 @@ async def trigger_window_change(self, event) -> None:
if new_state == "unknown":
_LOGGER.warning(
"better_thermostat %s: Window sensor state is unknown, assuming window is open",
self.name,
self.device_name,
)
# window was opened, disable heating power calculation for this period
@@ -47,45 +49,65 @@ async def trigger_window_change(self, event) -> None:
new_window_open = False
else:
_LOGGER.error(
f"better_thermostat {self.name}: New window sensor state '{new_state}' not recognized"
f"better_thermostat {self.device_name}: New window sensor state '{new_state}' not recognized"
)
ir.async_create_issue(
hass=self.hass,
domain=DOMAIN,
issue_id=f"missing_entity_{self.device_name}",
issue_title=f"better_thermostat {self.device_name} has invalid window sensor state",
issue_severity="error",
issue_description=f"better_thermostat {self.device_name} has invalid window sensor state: {new_state}",
issue_category="config",
issue_suggested_action="Please check the window sensor",
)
return
# make sure to skip events which do not change the saved window state:
if new_window_open == old_window_open:
_LOGGER.debug(
f"better_thermostat {self.name}: Window state did not change, skipping event"
f"better_thermostat {self.device_name}: Window state did not change, skipping event"
)
return
await self.window_queue_task.put(new_window_open)
async def window_queue(self):
while True:
window_event_to_process = await self.window_queue_task.get()
if window_event_to_process is not None:
if window_event_to_process:
_LOGGER.debug(
f"better_thermostat {self.name}: Window opened, waiting {self.window_delay} seconds before continuing"
)
await asyncio.sleep(self.window_delay)
else:
_LOGGER.debug(
f"better_thermostat {self.name}: Window closed, waiting {self.window_delay_after} seconds before continuing"
)
await asyncio.sleep(self.window_delay_after)
# remap off on to true false
current_window_state = True
if self.hass.states.get(self.window_id).state == STATE_OFF:
current_window_state = False
# make sure the current state is the suggested change state to prevent a false positive:
if current_window_state == window_event_to_process:
self.window_open = window_event_to_process
self.async_write_ha_state()
if not self.control_queue_task.empty():
empty_queue(self.control_queue_task)
await self.control_queue_task.put(self)
self.window_queue_task.task_done()
try:
while True:
window_event_to_process = await self.window_queue_task.get()
try:
if window_event_to_process is not None:
if window_event_to_process:
_LOGGER.debug(
f"better_thermostat {self.device_name}: Window opened, waiting {self.window_delay} seconds before continuing"
)
await asyncio.sleep(self.window_delay)
else:
_LOGGER.debug(
f"better_thermostat {self.device_name}: Window closed, waiting {self.window_delay_after} seconds before continuing"
)
await asyncio.sleep(self.window_delay_after)
# remap off on to true false
current_window_state = True
if self.hass.states.get(self.window_id).state == STATE_OFF:
current_window_state = False
# make sure the current state is the suggested change state to prevent a false positive:
if current_window_state == window_event_to_process:
self.window_open = window_event_to_process
self.async_write_ha_state()
if not self.control_queue_task.empty():
empty_queue(self.control_queue_task)
await self.control_queue_task.put(self)
except asyncio.CancelledError:
raise
finally:
self.window_queue_task.task_done()
except asyncio.CancelledError:
_LOGGER.debug(
f"better_thermostat {self.device_name}: Window queue task cancelled"
)
raise
def empty_queue(q: asyncio.Queue):
@@ -2,7 +2,8 @@
"domain": "better_thermostat",
"name": "Better Thermostat",
"after_dependencies": [
"climate"
"climate",
"zwave_js"
],
"codeowners": [
"@kartoffeltoby"
@@ -16,5 +17,5 @@
"iot_class": "local_push",
"issue_tracker": "https://github.com/KartoffelToby/better_thermostat/issues",
"requirements": [],
"version": "1.4.0"
"version": "1.7.0"
}
@@ -1,8 +1,11 @@
def fix_local_calibration(self, entity_id, offset):
if (self.cur_temp - 0.5) <= self.bt_target_temp:
offset -= 2.5
elif (self.cur_temp + 0.10) >= self.bt_target_temp:
_cur_external_temp = self.cur_temp
_target_temp = self.bt_target_temp
if (_cur_external_temp + 0.1) >= _target_temp:
offset = round(offset + 0.5, 1)
elif (_cur_external_temp + 0.5) >= _target_temp:
offset -= 2.5
return offset
@@ -1,8 +1,11 @@
def fix_local_calibration(self, entity_id, offset):
if (self.cur_temp - 0.5) <= self.bt_target_temp:
offset -= 2.5
elif (self.cur_temp + 0.10) >= self.bt_target_temp:
_cur_external_temp = self.cur_temp
_target_temp = self.bt_target_temp
if (_cur_external_temp + 0.1) >= _target_temp:
offset = round(offset + 0.5, 1)
elif (_cur_external_temp + 0.5) >= _target_temp:
offset -= 2.5
return offset
@@ -37,7 +37,7 @@ async def override_set_hvac_mode(self, entity_id, hvac_mode):
model = self.real_trvs[entity_id]["model"]
if model == "TV02-Zigbee" and hvac_mode != HVACMode.OFF:
_LOGGER.debug(
f"better_thermostat {self.name}: TRV {entity_id} device quirk hvac trv02-zigbee active"
f"better_thermostat {self.device_name}: TRV {entity_id} device quirk hvac trv02-zigbee active"
)
await self.hass.services.async_call(
"climate",
@@ -66,7 +66,7 @@ async def override_set_temperature(self, entity_id, temperature):
model = self.real_trvs[entity_id]["model"]
if model == "TV02-Zigbee":
_LOGGER.debug(
f"better_thermostat {self.name}: TRV {entity_id} device quirk trv02-zigbee active"
f"better_thermostat {self.device_name}: TRV {entity_id} device quirk trv02-zigbee active"
)
await self.hass.services.async_call(
"climate",
@@ -1,52 +1,43 @@
save_current_target_temperature:
name: Save current Temperature
description: Save the current target temperature for later restore.
name: Save target temperature
description: Save the current target temperature for later use.
target:
entity:
domain: climate
integration: better_thermostat
device:
integration: better_thermostat
restore_saved_target_temperature:
name: Restore temperature
description: Restore the saved target temperature.
name: Restore target temperature
description: Restore the previously saved target temperature.
target:
entity:
domain: climate
integration: better_thermostat
device:
integration: better_thermostat
reset_heating_power:
name: Reset heating power
description: Reset heating power to default value.
description: Reset the heating power to its default value.
target:
entity:
domain: climate
integration: better_thermostat
device:
integration: better_thermostat
set_temp_target_temperature:
name: Set eco temperature
description: Set the target temperature to a temporay like night mode, and save the old one.
name: Set temporary (ECO) target temperature
description: Set a temporary target temperature and save the current temperature.
fields:
temperature:
name: Target temperature
description: The target temperature to set.
example: 18.5
name: Temperature
description: New target temperature to set
required: true
advanced: true
selector:
number:
min: 0
max: 35
step: 0.5
mode: box
unit_of_measurement: °C
target:
entity:
domain: climate
integration: better_thermostat
device:
integration: better_thermostat
@@ -2,38 +2,38 @@
"config": {
"step": {
"user": {
"description": "Setup your Better Thermostat to integrate with Home Assistant\n**If you need more info: https://better-thermostat.org/configuration#first-step** ",
"description": "Setup your Better Thermostat to integrate with Home Assistant\n**If you need more info: https://better-thermostat.org/configuration#first-step**",
"data": {
"name": "Name",
"thermostat": "The real thermostat",
"cooler": "The cooling device (optional) | Preview BETA",
"cooler": "The cooling device (optional)",
"temperature_sensor": "Temperature sensor",
"humidity_sensor": "Humidity sensor",
"window_sensors": "Window sensor",
"off_temperature": "The outdoor temperature when the thermostat turn off",
"tolerance": "Tolerance, to prevent the thermostat from turning on and off too often.",
"window_off_delay": "Delay before the thermostat turn off when the window is opened",
"window_off_delay_after": "Delay before the thermostat turn on when the window is closed",
"outdoor_sensor": "If you have an outdoor sensor, you can use it to get the outdoor temperature",
"weather": "Your weather entity to get the outdoor temperature"
"off_temperature": "The outdoor temperature when the thermostat should turn off",
"tolerance": "Tolerance, to prevent the thermostat from turning on and off too often",
"window_off_delay": "Delay before the thermostat should turn off when the window is opened",
"window_off_delay_after": "Delay before the thermostat should turn on when the window is closed",
"outdoor_sensor": "Outdoor temperature sensor",
"weather": "Weather entity to get the outdoor temperature"
}
},
"advanced": {
"description": "Advanced configuration {trv}\n***Info about calibration types: https://better-thermostat.org/configuration#second-step***",
"data": {
"protect_overheating": "Overheating protection?",
"heat_auto_swapped": "If the auto means heat for your TRV and you want to swap it",
"heat_auto_swapped": "If 'auto' means 'heat' for your TRV and you want to swap it",
"child_lock": "Ignore all inputs on the TRV like a child lock",
"homaticip": "If you use HomaticIP, you should enable this to slow down the requests to prevent the duty cycle",
"homematicip": "If you use HomematicIP, you should enable this to slow down the requests to prevent the duty cycle",
"valve_maintenance": "If your thermostat has no own maintenance mode, you can use this one",
"calibration": "Calibration Type",
"calibration": "Calibration type",
"calibration_mode": "Calibration mode",
"no_off_system_mode": "If your TRV can't handle the off mode, you can enable this to use target temperature 5°C instead"
"no_off_system_mode": "If your TRV doesn't support the 'off' mode, enable this to use target temperature 5°C instead"
},
"data_description": {
"protect_overheating": "Some TRVs doest close the valve completly when the temperature is reached. Or the radiator have a lot of rest heat. This can cause overheating. This option can prevent this.",
"calibration_mode": "The kind how the calibration should be calculated\n***Normal***: In this mode the TRV internal temperature sensor is fixed by the external temperature sensor.\n***Aggresive***: In this mode the TRV internal temperature sensor is fixed by the external temperature sensor but set much lower/higher to get a quicker boost.\n***AI Time Based***: In this mode the TRV internal temperature sensor is fixed by the external temperature sensor, but the value is calculated by a custom algorithm to improve the TRV internal algorithm.",
"calibration": "How the calibration should be applied on the TRV (Target temp or offset)\n***Target Temperature Based***: Apply the calibration to the target temperature.\n***Offset Based***: Apply the calibration to the offset."
"protect_overheating": "Some TRVs don't close the valve completely when the temperature is reached. Or the radiator has a lot of rest heat. This can cause overheating. This option can prevent this.",
"calibration_mode": "How the calibration is calculated\n***Normal***: In this mode, the TRV internal temperature sensor is fixed by the external temperature sensor.\n***Aggresive***: In this mode, the TRV internal temperature sensor is fixed by the external temperature sensor but set much lower/higher to get a quicker boost.\n***AI Time Based***: In this mode, the TRV internal temperature sensor is fixed by the external temperature sensor, but the value is calculated by a custom algorithm to improve the TRV internal algorithm.",
"calibration": "How the calibration is applied on the TRV\n***Target Temperature Based***: Apply the calibration to the target temperature.\n***Offset Based***: Apply the calibration to the temperature offset."
}
},
"confirm": {
@@ -42,14 +42,14 @@
}
},
"error": {
"failed": "something went wrong.",
"failed": "Something went wrong.",
"no_name": "Please enter a name.",
"no_off_mode": "You device is very special and has no off mode :(\nBetter Thermostat will use the minimal target temp instead.",
"no_off_mode": "Your device is special and has no off mode :(\nBetter Thermostat will use the minimal target temp instead.",
"no_outside_temp": "You have no outside temperature sensor. Better Thermostat will use the weather entity instead."
},
"abort": {
"single_instance_allowed": "Only a single Thermostat for each real is allowed.",
"no_devices_found": "No thermostat entity found, make sure you have a climate entity in your home assistant"
"single_instance_allowed": "Only a single Better Thermostat is allowed for each real thermostat.",
"no_devices_found": "No thermostat entity found, make sure you have a climate entity in your Home Assistant"
}
},
"options": {
@@ -62,17 +62,17 @@
"temperature_sensor": "Temperature Sensor",
"humidity_sensor": "Humidity sensor",
"window_sensors": "Window Sensor",
"off_temperature": "The outdoor temperature when the thermostat turn off",
"tolerance": "Tolerance, to prevent the thermostat from turning on and off too often.",
"window_off_delay": "Delay before the thermostat turn off when the window is opened",
"window_off_delay_after": "Delay before the thermostat turn on when the window is closed",
"outdoor_sensor": "If you have an outdoor sensor, you can use it to get the outdoor temperature",
"off_temperature": "The outdoor temperature when the thermostat should turn off",
"tolerance": "Tolerance, to prevent the thermostat from turning on and off too often",
"window_off_delay": "Delay before the thermostat should turn off when the window is opened",
"window_off_delay_after": "Delay before the thermostat should turn on when the window is closed",
"outdoor_sensor": "Outdoor temperature sensor",
"weather": "Weather entity to get the outdoor temperature",
"valve_maintenance": "If your thermostat has no own maintenance mode, you can use this one",
"calibration": "The sort of calibration https://better-thermostat.org/configuration#second-step",
"weather": "Your weather entity to get the outdoor temperature",
"heat_auto_swapped": "If the auto means heat for your TRV and you want to swap it",
"child_lock": "Ignore all inputs on the TRV like a child lock",
"homaticip": "If you use HomaticIP, you should enable this to slow down the requests to prevent the duty cycle"
"homematicip": "If you use HomematicIP, you should enable this to slow down the requests to prevent the duty cycle"
}
},
"advanced": {
@@ -81,17 +81,16 @@
"protect_overheating": "Overheating protection?",
"heat_auto_swapped": "If the auto means heat for your TRV and you want to swap it",
"child_lock": "Ignore all inputs on the TRV like a child lock",
"homaticip": "If you use HomaticIP, you should enable this to slow down the requests to prevent the duty cycle",
"homematicip": "If you use HomematicIP, you should enable this to slow down the requests to prevent the duty cycle",
"valve_maintenance": "If your thermostat has no own maintenance mode, you can use this one",
"calibration": "The sort of calibration you want to use",
"calibration": "Calibration type",
"calibration_mode": "Calibration mode",
"no_off_system_mode": "If your TRV can't handle the off mode, you can enable this to use target temperature 5°C instead"
"no_off_system_mode": "If your TRV doesn't support the 'off' mode, enable this to use target temperature 5°C instead"
},
"data_description": {
"protect_overheating": "Some TRVs doest close the valve completly when the temperature is reached. Or the radiator have a lot of rest heat. This can cause overheating. This option can prevent this.",
"calibration_mode": "The kind how the calibration should be calculated\n***Normal***: In this mode the TRV internal temperature sensor is fixed by the external temperature sensor.\n***Aggresive***: In this mode the TRV internal temperature sensor is fixed by the external temperature sensor but set much lower/higher to get a quicker boost.\n***AI Time Based***: In this mode the TRV internal temperature sensor is fixed by the external temperature sensor, but the value is calculated by a custom algorithm to improve the TRV internal algorithm.",
"calibration": "How the calibration should be applied on the TRV (Target temp or offset)\n***Target Temperature Based***: Apply the calibration to the target temperature.\n***Offset Based***: Apply the calibration to the offset."
}
"protect_overheating": "Some TRVs don't close the valve completely when the temperature is reached. Or the radiator has a lot of rest heat. This can cause overheating. This option can prevent this.",
"calibration_mode": "How the calibration is calculated\n***Normal***: In this mode, the TRV internal temperature sensor is fixed by the external temperature sensor.\n***Aggresive***: In this mode, the TRV internal temperature sensor is fixed by the external temperature sensor but set much lower/higher to get a quicker boost.\n***AI Time Based***: In this mode, the TRV internal temperature sensor is fixed by the external temperature sensor, but the value is calculated by a custom algorithm to improve the TRV internal algorithm.",
"calibration": "How the calibration is applied on the TRV\n***Target Temperature Based***: Apply the calibration to the target temperature.\n***Offset Based***: Apply the calibration to the temperature offset." }
}
}
},
@@ -102,7 +101,7 @@
"step": {
"confirm": {
"title": "The related entity {entity} is missing",
"description": "The reason for this is that the entity ({entity}) is not available in your Home Assistant.\n\nYou can fix this by checking if the batterie of the device is full or reconnect it to HA. Make sure that the entity is back in HA before you continue."
"description": "The reason for this is that the entity ({entity}) is not available in your Home Assistant.\n\nYou can fix this by checking if the battery of the device is full or reconnecting it to HA. Make sure that the entity is back in HA before you continue."
}
}
}
@@ -123,7 +122,7 @@
},
"set_temp_target_temperature": {
"name": "Set eco temperature",
"description": "Set the target temperature to a temporay like night mode, and save the old one."
"description": "Set the target temperature to a temporary like night mode, and save the old one."
}
}
}
}
@@ -7,30 +7,33 @@
"data": {
"name": "Navn",
"thermostat": "Den rigtige termostat",
"cooler": "Køleenhed (valgfri)",
"temperature_sensor": "Temperatur måler",
"humidity_sensor": "Fugtighedssensor",
"window_sensors": "Vinduessensor",
"off_temperature": "Udetemperaturen, når termostaten slukker",
"tolerance": "Tolerancen for at forhindre, at termostaten tænder og slukker for ofte.",
"window_off_delay": "Forsinkelse, før termostaten slukker, når vinduet er åbent, og tændt, når vinduet er lukket",
"window_off_delay_after": "Forsinkelse, før termostaten tænder, når vinduet er lukket",
"outdoor_sensor": "Hvis du har en udeføler, kan du bruge den til at få udetemperaturen",
"weather": "Din vejrentitet for at få udendørstemperaturen"
}
},
"advanced": {
"description": "Avanceret konfiguration\n\n**Info om kalibreringstyper: https://better-thermostat.org/configuration#second-step** ",
"description": "Avanceret konfiguration {trv}\n***Info om kalibreringstyper: https://better-thermostat.org/configuration#second-step*** ",
"data": {
"protect_overheating": "Overophedningsbeskyttelse?",
"heat_auto_swapped": "Hvis auto betyder varme for din TRV og du vil bytte det",
"child_lock": "Ignorer alle input på TRV som en børnesikring",
"homaticip": "Hvis du bruger HomaticIP, bør du aktivere dette for at bremse anmodningerne for at forhindre arbejdscyklus",
"homematicip": "Hvis du bruger HomematicIP, bør du aktivere dette for at bremse anmodningerne for at forhindre arbejdscyklus",
"valve_maintenance": "Hvis din termostat ikke har nogen egen vedligeholdelsestilstand, kan du bruge denne",
"calibration": "Den slags kalibrering du vil bruge",
"no_off_system_mode": "Hvis din TRV ikke kan håndtere den slukkede tilstand, kan du aktivere denne for at bruge måltemperatur 5°C i stedet",
"calibration_mode": "Kalibreringstilstand",
"protect_overheating": "Overophedningsbeskyttelse?"
"no_off_system_mode": "Hvis din TRV ikke kan håndtere den slukkede tilstand, kan du aktivere denne for at bruge måltemperatur 5°C i stedet"
},
"data_description": {
"protect_overheating": "Nogle TRV'er lukker ikke ventilen helt, når temperaturen er nået. Eller radiatoren har meget hvilevarme. Dette kan forårsage overophedning. Denne mulighed kan forhindre dette.",
"calibration_mode": "Den slags, hvordan kalibreringen skal beregnes\n***Normal***: I denne tilstand er TRV's interne temperaturføler fastgjort af den eksterne temperaturføler.\n***Aggressiv***: I denne tilstand er TRV's interne temperaturføler fikseret af den eksterne temperaturføler, men indstillet meget lavere/højere for at få et hurtigere løft.\n***AI-tidsbaseret***: I denne tilstand er TRV's interne temperatursensor fastsat af den eksterne temperatursensor, men værdien beregnes af en brugerdefineret algoritme for at forbedre TRV's interne algoritme.",
"calibration_mode": "Hvordan kalibreringen skal beregnes\n***Normal***: I denne tilstand er TRV's interne temperaturføler fastgjort af den eksterne temperaturføler.\n***Aggressiv***: I denne tilstand er TRV's interne temperaturføler fikseret af den eksterne temperaturføler, men indstillet meget lavere/højere for at få et hurtigere løft.\n***AI-tidsbaseret***: I denne tilstand er TRV's interne temperatursensor fastsat af den eksterne temperatursensor, men værdien beregnes af en brugerdefineret algoritme for at forbedre TRV's interne algoritme.",
"calibration": "Hvordan kalibreringen skal anvendes på TRV (Target temp or offset)\n***Måltemperaturbaseret***: Anvend kalibreringen til måltemperaturen.\n***Offset-baseret***: Anvend kalibreringen til offset."
}
},
@@ -40,10 +43,10 @@
}
},
"error": {
"no_outside_temp": "Du har ingen udetemperaturføler. Bedre termostat vil bruge vejret i stedet.",
"failed": "noget gik galt.",
"failed": "Noget gik galt.",
"no_name": "Indtast venligst et navn.",
"no_off_mode": "Din enhed er meget speciel og har ingen slukket tilstand :(\nDet virker alligevel, men du skal oprette en automatisering, der passer til dine specialer baseret på enhedsbegivenhederne"
"no_off_mode": "Din enhed er meget speciel og har ingen slukket tilstand :(\nDet virker alligevel, men du skal oprette en automatisering, der passer til dine specialer baseret på enhedsbegivenhederne",
"no_outside_temp": "Du har ingen udetemperaturføler. Better Thermostat vil bruge vejret i stedet."
},
"abort": {
"single_instance_allowed": "Kun en enkelt termostat for hver virkelige er tilladt.",
@@ -55,37 +58,39 @@
"user": {
"description": "Opdater dine Better Thermostat indstillinger",
"data": {
"name": "Navn",
"thermostat": "Den rigtige termostat",
"temperature_sensor": "Temperatur måler",
"humidity_sensor": "Fugtighedssensor",
"window_sensors": "Vinduessensor",
"off_temperature": "Udendørstemperaturen, når termostaten skal slukker",
"tolerance": "Tolerancen for at forhindre, at termostaten tænder og slukker for ofte.",
"window_off_delay": "Forsinkelse, før termostaten slukker, når vinduet er åbent, og tændt, når vinduet er lukket",
"window_off_delay_after": "Forsinkelse, før termostaten tænder, når vinduet er lukket",
"outdoor_sensor": "Har du en udendørsføler, kan du bruge den til at få udetemperaturen",
"valve_maintenance": "Hvis din termostat ikke har nogen egen vedligeholdelsestilstand, kan du bruge denne",
"calibration": "Den slags kalibrering https://better-thermostat.org/configuration#second-step",
"weather": "Din vejrentitet for at få udendørstemperaturen",
"heat_auto_swapped": "Hvis auto betyder varme til din TRV, og du ønsker at bytte den",
"child_lock": "Ignorer alle input på TRV som en børnesikring",
"homaticip": "Hvis du bruger HomaticIP, bør du aktivere dette for at bremse anmodningerne for at forhindre arbejdscyklus",
"name": "Navn",
"thermostat": "Den rigtige termostat"
"homaticip": "Hvis du bruger HomematicIP, bør du aktivere dette for at bremse anmodningerne for at forhindre arbejdscyklus"
}
},
"advanced": {
"description": "Avanceret konfiguration\n\n**Info om kalibreringstyper: https://better-thermostat.org/configuration#second-step** ",
"description": "Avanceret konfiguration {trv}\n***Info om kalibreringstyper: https://better-thermostat.org/configuration#second-step*** ",
"data": {
"protect_overheating": "Overophedningsbeskyttelse?",
"heat_auto_swapped": "Hvis auto betyder varme for din TRV og du vil bytte det",
"child_lock": "Ignorer alle input på TRV som en børnesikring",
"homaticip": "Hvis du bruger HomaticIP, bør du aktivere dette for at bremse anmodningerne for at forhindre arbejdscyklus",
"homematicip": "Hvis du bruger HomematicIP, bør du aktivere dette for at bremse anmodningerne for at forhindre arbejdscyklus",
"valve_maintenance": "Hvis din termostat ikke har nogen egen vedligeholdelsestilstand, kan du bruge denne",
"calibration": "Den slags kalibrering du vil bruge",
"protect_overheating": "Overophedningsbeskyttelse?",
"calibration_mode": "Kalibreringstilstand",
"no_off_system_mode": "Hvis din TRV ikke kan håndtere den slukkede tilstand, kan du aktivere denne for at bruge måltemperatur 5°C i stedet"
},
"data_description": {
"protect_overheating": "Nogle TRV'er lukker ikke ventilen helt, når temperaturen er nået. Eller radiatoren har meget hvilevarme. Dette kan forårsage overophedning. Denne mulighed kan forhindre dette.",
"calibration_mode": "Den slags, hvordan kalibreringen skal beregnes\n***Normal***: I denne tilstand er TRV's interne temperaturføler fikseret af den eksterne temperaturføler.\n***Aggressiv***: I denne tilstand er TRV's interne temperaturføler fikseret af den eksterne temperaturføler, men indstillet meget lavere/højere for at få et hurtigere løft.\n***AI-tidsbaseret***: I denne tilstand er TRV's interne temperatursensor fastsat af den eksterne temperatursensor, men værdien beregnes af en brugerdefineret algoritme for at forbedre TRV's interne algoritme.",
"calibration_mode": "Hvordan kalibreringen skal beregnes\n***Normal***: I denne tilstand er TRV's interne temperaturføler fikseret af den eksterne temperaturføler.\n***Aggressiv***: I denne tilstand er TRV's interne temperaturføler fikseret af den eksterne temperaturføler, men indstillet meget lavere/højere for at få et hurtigere løft.\n***AI-tidsbaseret***: I denne tilstand er TRV's interne temperatursensor fastsat af den eksterne temperatursensor, men værdien beregnes af en brugerdefineret algoritme for at forbedre TRV's interne algoritme.",
"calibration": "Hvordan kalibreringen skal anvendes på TRV (Target temp or offset)\n***Måltemperaturbaseret***: Anvend kalibreringen til måltemperaturen.\n***Offset-baseret***: Anvend kalibreringen til offset."
}
}
@@ -103,5 +108,23 @@
}
}
}
},
"services": {
"save_current_target_temperature": {
"name": "Gem nuværende temperatur",
"description": "Gem den nuværende måltemperatur til senere gendannelse."
},
"restore_saved_target_temperature": {
"name": "Gendan temperatur",
"description": "Gendan den gemte måltemperatur."
},
"reset_heating_power": {
"name": "Nulstil opvarmningskraft",
"description": "Nulstil opvarmningskraft til standardværdi."
},
"set_temp_target_temperature": {
"name": "Indstil eco-temperatur",
"description": "Indstil måltemperaturen til en midlertidig som nattilstand, og gem den gamle."
}
}
}
}
@@ -3,11 +3,11 @@
"config": {
"step": {
"user": {
"description": "Einrichtung von Better Thermostat mit Home Assistant\n**Für mehr Informationen: https://better-thermostat.org/configuration#first-step** ",
"description": "Einrichtung von Better Thermostat mit Home Assistant\n**Für mehr Informationen: https://better-thermostat.org/configuration#first-step**",
"data": {
"name": "Name",
"thermostat": "Das reale Thermostat",
"cooler": "Klimmagerät AC (optional) | Preview BETA",
"cooler": "Klimagerät AC (optional)",
"temperature_sensor": "Externer Temperatursensor",
"humidity_sensor": "Luftfeuchtigkeitssensor",
"window_sensors": "Fenstersensor(en)",
@@ -25,7 +25,7 @@
"protect_overheating": "Überhitzung verhindern?",
"heat_auto_swapped": "Tauscht die Modi auto und heat, falls diese bei dem realen Thermostat vertauscht sind.",
"child_lock": "Ignoriere alle manuellen Einstellungen am realen Thermostat (Kindersicherung).",
"homaticip": "Wenn du HomaticIP nutzt, solltest du diese Option aktivieren, um die Funk-Übertragung zu reduzieren.",
"homematicip": "Wenn du HomematicIP nutzt, solltest du diese Option aktivieren, um die Funk-Übertragung zu reduzieren.",
"valve_maintenance": "Soll BT die Wartung des Thermostats übernehmen?",
"calibration": "Kalibrierungstyp",
"calibration_mode": "Kalibrierungsmodus",
@@ -71,7 +71,7 @@
"weather": "Die Wetter-Entität für die Außentemperatur.",
"valve_maintenance": "Wenn Ihr Thermostat keinen eigenen Wartungsmodus hat, können Sie diesen verwenden.",
"child_lock": "Ignorieren Sie alle Eingaben am TRV wie eine Kindersicherung.",
"homaticip": "Wenn Sie HomaticIP verwenden, sollten Sie dies aktivieren, um die Anfragen zu verlangsamen und den Duty Cycle zu verhindern.",
"homematicip": "Wenn Sie HomematicIP verwenden, sollten Sie dies aktivieren, um die Anfragen zu verlangsamen und den Duty Cycle zu verhindern.",
"heat_auto_swapped": "Wenn das Auto Wärme für Ihr TRV bedeutet und Sie es austauschen möchten.",
"calibration": "Die Art der Kalibrierung https://better-thermostat.org/configuration#second-step"
}
@@ -82,7 +82,7 @@
"protect_overheating": "Überhitzung verhindern?",
"heat_auto_swapped": "Tauscht die Modi auto und heat, falls diese bei dem realen Thermostat vertauscht sind.",
"child_lock": "Ignoriere alle manuellen Einstellungen am realen Thermostat (Kindersicherung).",
"homaticip": "Wenn du HomaticIP nutzt, solltest du diese Option aktivieren, um die Funk-Übertragung zu reduzieren.",
"homematicip": "Wenn du HomematicIP nutzt, solltest du diese Option aktivieren, um die Funk-Übertragung zu reduzieren.",
"valve_maintenance": "Soll BT die Wartung des Thermostats übernehmen?",
"calibration": "Kalibrierungstyp",
"calibration_mode": "Kalibrierungsmodus",
@@ -127,4 +127,4 @@
"description": "Speichert eine ECO Temperatur, die für den ECO Modus genutzt wird."
}
}
}
}
@@ -3,18 +3,18 @@
"config": {
"step": {
"user": {
"description": "Setup your Better Thermostat to integrate with Home Assistant\n**If you need more info: https://better-thermostat.org/configuration#first-step** ",
"description": "Setup your Better Thermostat to integrate with Home Assistant\n**If you need more info: https://better-thermostat.org/configuration#first-step**",
"data": {
"name": "Name",
"thermostat": "The real thermostat",
"cooler": "The cooling device (optional) | Preview BETA",
"cooler": "The cooling device (optional)",
"temperature_sensor": "Temperature sensor",
"humidity_sensor": "Humidity sensor",
"window_sensors": "Window sensor",
"off_temperature": "The outdoor temperature when the thermostat turn off",
"off_temperature": "The outdoor temperature when the thermostat turns off",
"tolerance": "Tolerance, to prevent the thermostat from turning on and off too often.",
"window_off_delay": "Delay before the thermostat turn off when the window is opened",
"window_off_delay_after": "Delay before the thermostat turn on when the window is closed",
"window_off_delay": "Delay before the thermostat turns off when the window is opened",
"window_off_delay_after": "Delay before the thermostat turns on when the window is closed",
"outdoor_sensor": "If you have an outdoor sensor, you can use it to get the outdoor temperature",
"weather": "Your weather entity to get the outdoor temperature"
}
@@ -25,15 +25,15 @@
"protect_overheating": "Overheating protection?",
"heat_auto_swapped": "If the auto means heat for your TRV and you want to swap it",
"child_lock": "Ignore all inputs on the TRV like a child lock",
"homaticip": "If you use HomaticIP, you should enable this to slow down the requests to prevent the duty cycle",
"homematicip": "If you use HomematicIP, you should enable this to slow down the requests to prevent the duty cycle",
"valve_maintenance": "If your thermostat has no own maintenance mode, you can use this one",
"calibration": "Calibration Type",
"calibration_mode": "Calibration mode",
"no_off_system_mode": "If your TRV can't handle the off mode, you can enable this to use target temperature 5°C instead"
},
"data_description": {
"protect_overheating": "Some TRVs doest close the valve completly when the temperature is reached. Or the radiator have a lot of rest heat. This can cause overheating. This option can prevent this.",
"calibration_mode": "The kind how the calibration should be calculated\n***Normal***: In this mode the TRV internal temperature sensor is fixed by the external temperature sensor.\n***Aggresive***: In this mode the TRV internal temperature sensor is fixed by the external temperature sensor but set much lower/higher to get a quicker boost.\n***AI Time Based***: In this mode the TRV internal temperature sensor is fixed by the external temperature sensor, but the value is calculated by a custom algorithm to improve the TRV internal algorithm.",
"protect_overheating": "Some TRVs don't close the valve completely when the temperature is reached. Or the radiator has a lot of rest heat. This can cause overheating. This option can prevent this.",
"calibration_mode": "The kind how the calibration should be calculated\n***Normal***: In this mode, the TRV internal temperature sensor is fixed by the external temperature sensor.\n***Aggresive***: In this mode, the TRV internal temperature sensor is fixed by the external temperature sensor but set much lower/higher to get a quicker boost.\n***AI Time Based***: In this mode, the TRV internal temperature sensor is fixed by the external temperature sensor, but a custom algorithm calculates the value to improve the TRV internal algorithm.",
"calibration": "How the calibration should be applied on the TRV (Target temp or offset)\n***Target Temperature Based***: Apply the calibration to the target temperature.\n***Offset Based***: Apply the calibration to the offset."
}
},
@@ -43,9 +43,9 @@
}
},
"error": {
"failed": "something went wrong.",
"failed": "Something went wrong.",
"no_name": "Please enter a name.",
"no_off_mode": "You device is very special and has no off mode :(\nBetter Thermostat will use the minimal target temp instead.",
"no_off_mode": "Your device is very special and has no off mode :(\nBetter Thermostat will use the minimal target temp instead.",
"no_outside_temp": "You have no outside temperature sensor. Better Thermostat will use the weather entity instead."
},
"abort": {
@@ -63,17 +63,17 @@
"temperature_sensor": "Temperature Sensor",
"humidity_sensor": "Humidity sensor",
"window_sensors": "Window Sensor",
"off_temperature": "The outdoor temperature when the thermostat turn off",
"off_temperature": "The outdoor temperature when the thermostat turns off",
"tolerance": "Tolerance, to prevent the thermostat from turning on and off too often.",
"window_off_delay": "Delay before the thermostat turn off when the window is opened",
"window_off_delay_after": "Delay before the thermostat turn on when the window is closed",
"window_off_delay": "Delay before the thermostat turns off when the window is opened",
"window_off_delay_after": "Delay before the thermostat turns on when the window is closed",
"outdoor_sensor": "If you have an outdoor sensor, you can use it to get the outdoor temperature",
"valve_maintenance": "If your thermostat has no own maintenance mode, you can use this one",
"calibration": "The sort of calibration https://better-thermostat.org/configuration#second-step",
"weather": "Your weather entity to get the outdoor temperature",
"heat_auto_swapped": "If the auto means heat for your TRV and you want to swap it",
"child_lock": "Ignore all inputs on the TRV like a child lock",
"homaticip": "If you use HomaticIP, you should enable this to slow down the requests to prevent the duty cycle"
"homematicip": "If you use HomematicIP, you should enable this to slow down the requests to prevent the duty cycle"
}
},
"advanced": {
@@ -82,15 +82,15 @@
"protect_overheating": "Overheating protection?",
"heat_auto_swapped": "If the auto means heat for your TRV and you want to swap it",
"child_lock": "Ignore all inputs on the TRV like a child lock",
"homaticip": "If you use HomaticIP, you should enable this to slow down the requests to prevent the duty cycle",
"homematicip": "If you use HomematicIP, you should enable this to slow down the requests to prevent the duty cycle",
"valve_maintenance": "If your thermostat has no own maintenance mode, you can use this one",
"calibration": "The sort of calibration you want to use",
"calibration_mode": "Calibration mode",
"no_off_system_mode": "If your TRV can't handle the off mode, you can enable this to use target temperature 5°C instead"
},
"data_description": {
"protect_overheating": "Some TRVs doest close the valve completly when the temperature is reached. Or the radiator have a lot of rest heat. This can cause overheating. This option can prevent this.",
"calibration_mode": "The kind how the calibration should be calculated\n***Normal***: In this mode the TRV internal temperature sensor is fixed by the external temperature sensor.\n***Aggresive***: In this mode the TRV internal temperature sensor is fixed by the external temperature sensor but set much lower/higher to get a quicker boost.\n***AI Time Based***: In this mode the TRV internal temperature sensor is fixed by the external temperature sensor, but the value is calculated by a custom algorithm to improve the TRV internal algorithm.",
"protect_overheating": "Some TRVs don't close the valve completely when the temperature is reached. Or the radiator has a lot of rest heat. This can cause overheating. This option can prevent this.",
"calibration_mode": "The kind how the calibration should be calculated\n***Normal***: In this mode, the TRV internal temperature sensor is fixed by the external temperature sensor.\n***Aggresive***: In this mode, the TRV internal temperature sensor is fixed by the external temperature sensor but set much lower/higher to get a quicker boost.\n***AI Time Based***: In this mode the TRV internal temperature sensor is fixed by the external temperature sensor, but a custom algorithm calculates the value to improve the TRV internal algorithm.",
"calibration": "How the calibration should be applied on the TRV (Target temp or offset)\n***Target Temperature Based***: Apply the calibration to the target temperature.\n***Offset Based***: Apply the calibration to the offset."
}
}
@@ -103,7 +103,7 @@
"step": {
"confirm": {
"title": "The related entity {entity} is missing",
"description": "The reason for this is that the entity ({entity}) is not available in your Home Assistant.\n\nYou can fix this by checking if the batterie of the device is full or reconnect it to HA. Make sure that the entity is back in HA before you continue."
"description": "The reason for this is that the entity ({entity}) is not available in your Home Assistant.\n\nYou can fix this by checking if the battery of the device is full or reconnecting it to HA. Make sure that the entity is back in HA before you continue."
}
}
}
@@ -124,7 +124,7 @@
},
"set_temp_target_temperature": {
"name": "Set eco temperature",
"description": "Set the target temperature to a temporay like night mode, and save the old one."
"description": "Set the target temperature to a temporary like night mode, and save the old one."
}
}
}
}
@@ -3,105 +3,128 @@
"config": {
"step": {
"user": {
"description": "Configuration de Better Thermostat pour l'intégrer à Home Assistant\n**If you need more info: https://better-thermostat.org/configuration#first-step** ",
"description": "Configurez Better Thermostat pour l'intégrer à Home Assistant\n**Si vous avez besoin de plus d'informations : https://better-thermostat.org/configuration#first-step**",
"data": {
"name": "Nom",
"thermostat": "Le véritable thermostat",
"thermostat": "Le vrai thermostat",
"cooler": "Le dispositif de refroidissement (optionnel)",
"temperature_sensor": "Capteur de température",
"humidity_sensor": "Capteur d'humidité",
"window_sensors": "Capteur de fenêtre",
"off_temperature": "La température extérieure lorsque le thermostat s'éteint",
"window_off_delay": "Délai avant que le thermostat ne s'éteigne lorsque la fenêtre est ouverte et ne s'allume lorsque la fenêtre est fermée",
"off_temperature": "La température extérieure à laquelle le thermostat s'éteint",
"tolerance": "Tolérance, pour éviter que le thermostat ne s'allume et s'éteigne trop souvent.",
"window_off_delay": "Délai avant que le thermostat ne s'éteigne lorsque la fenêtre est ouverte",
"window_off_delay_after": "Délai avant que le thermostat ne s'allume lorsque la fenêtre est fermée",
"outdoor_sensor": "Si vous avez un capteur extérieur, vous pouvez l'utiliser pour obtenir la température extérieure",
"weather": "Votre entité météo pour obtenir la température extérieure"
}
},
"advanced": {
"description": "Configuration avancée\n\n**Informations sur les types d'étalonnage : https://better-thermostat.org/configuration#second-step**",
"description": "Configuration avancée {trv}\n***Informations sur les types de calibrage : https://better-thermostat.org/configuration#second-step***",
"data": {
"heat_auto_swapped": "Si auto signifie chauffer pour votre TRV et que vous voulez l'inverser (pour les utilisateurs de Google Home)",
"child_lock": "Ignorer toutes les entrées sur le TRV comme un vérouillage enfant",
"homaticip": "Si vous utilisez HomaticIP, vous devez l'activer pour ralentir les requêtes afin d'éviter le cycle de service",
"valve_maintenance": "Si votre thermostat n'a pas de mode de maintenance intégré, vous pouvez utiliser celui-ci",
"calibration": "The sort of calibration",
"no_off_system_mode": "Si votre TRV ne peut pas gérer le mode arrêt, vous pouvez l'activer pour utiliser la température cible de 5 °C à la place",
"calibration_mode": "Mode d'étalonnage",
"protect_overheating": "Protection contre la surchauffe ?"
"protect_overheating": "Protection contre la surchauffe ?",
"heat_auto_swapped": "Si le mode automatique signifie chauffage pour votre TRV et que vous voulez le permuter",
"child_lock": "Ignorer toutes les entrées sur le TRV comme une sécurité enfant",
"homematicip": "Si vous utilisez HomematicIP, vous devriez l'activer pour ralentir les demandes et éviter le cycle de fonctionnement excessif",
"valve_maintenance": "Si votre thermostat n'a pas de mode maintenance propre, vous pouvez utiliser celui-ci",
"calibration": "Type de calibrage",
"calibration_mode": "Mode de calibrage",
"no_off_system_mode": "Si votre TRV ne peut pas gérer le mode hors tension, vous pouvez activer ceci pour utiliser une température cible de 5°C à la place"
},
"data_description": {
"protect_overheating": "Certaines VTR ferment complètement la vanne lorsque la température est atteinte. Ou le radiateur a beaucoup de chaleur résiduelle. Cela peut provoquer une surchauffe. Cette option peut empêcher cela.",
"calibration_mode": "Le type de calcul de l'étalonnage\n***Normal*** : Dans ce mode, le capteur de température interne TRV est fixé par le capteur de température externe.\n***Agressif*** : dans ce mode, le capteur de température interne TRV est fixé par le capteur de température externe mais réglé beaucoup plus bas/plus haut pour obtenir une accélération plus rapide.\n*** AI Time Based *** : Dans ce mode, le capteur de température interne TRV est fixé par le capteur de température externe, mais la valeur est calculée par un algorithme personnalisé pour améliorer l'algorithme interne TRV.",
"calibration": "Comment l'étalonnage doit être appliqué sur la VTR (température cible ou décalage)\n***Basé sur la température cible*** : Appliquez l'étalonnage à la température cible.\n***Basé sur le décalage*** : Appliquez l'étalonnage au décalage."
"protect_overheating": "Certains TRVs ne ferment pas complètement la vanne lorsque la température est atteinte. Ou le radiateur a beaucoup de chaleur résiduelle. Cela peut provoquer une surchauffe. Cette option peut prévenir cela.",
"calibration_mode": "Le type de calcul du calibrage\n***Normal*** : Dans ce mode, le capteur de température interne du TRV est fixé par le capteur de température externe.\n***Aggressif*** : Dans ce mode, le capteur de température interne du TRV est fixé par le capteur de température externe mais réglé beaucoup plus bas/haut pour obtenir un boost plus rapide.\n***AI Time Based*** : Dans ce mode, le capteur de température interne du TRV est fixé par le capteur de température externe, mais la valeur est calculée par un algorithme personnalisé pour améliorer l'algorithme interne du TRV.",
"calibration": "Comment le calibrage doit être appliqué sur le TRV (Température cible ou décalage)\n***Basé sur la température cible*** : Appliquer le calibrage à la température cible.\n***Basé sur le décalage*** : Appliquer le calibrage au décalage."
}
},
"confirm": {
"title": "Confirmer l'ajout d'un meilleur thermostat",
"description": "Vous êtes sur le point d'ajouter \"{name}\" à Home Assistant.\nAvec {trv} comme vrai thermostat"
"title": "Confirmer l'ajout d'un Better Thermostat",
"description": "Vous êtes sur le point d'ajouter `{name}` à Home Assistant.\nAvec {trv} en tant que vrai thermostat"
}
},
"error": {
"no_name": "Veuillez entrer un nom.",
"failed": "quelque chose s'est mal passé.",
"no_outside_temp": "Vous devez définir un capteur de température extérieure ou une entité météorologique.",
"no_off_mode": "You device is very special and has no off mode :(\nIt work anyway, but you have to create a automation to fit your specials based on the device events"
"no_name": "Veuillez entrer un nom.",
"no_off_mode": "Votre appareil est très spécial et n'a pas de mode hors tension :(\nBetter Thermostat utilisera plutôt la température cible minimale.",
"no_outside_temp": "Vous n'avez pas de capteur de température extérieure. Better Thermostat utilisera plutôt l'entité météo."
},
"abort": {
"single_instance_allowed": "Un seul et unique thermostat est autorisé pour chaque thermostat réel",
"no_devices_found": "Aucune entité thermostat n'a été trouvée, assurez-vous d'avoir une entité climat dans votre home assistant"
"single_instance_allowed": "Seule une instance de thermostat pour chaque vrai thermostat est autorisée.",
"no_devices_found": "Aucune entité de thermostat trouvée, assurez-vous d'avoir une entité de climatisation dans votre Home Assistant"
}
},
"options": {
"step": {
"user": {
"description": "Mettez à jour vos paramètres de Better Thermostat",
"description": "Mettez à jour les paramètres de Better Thermostat",
"data": {
"name": "Nom",
"thermostat": "Le vrai thermostat",
"temperature_sensor": "Capteur de température",
"humidity_sensor": "Capteur d'humidité",
"window_sensors": "Capteur de fenêtre",
"off_temperature": "La température extérieure lorsque le thermostat s'éteint",
"window_off_delay": "Délai avant que le thermostat ne s'éteigne lorsque la fenêtre est ouverte et ne s'allume lorsque la fenêtre est fermée",
"off_temperature": "La température extérieure à laquelle le thermostat s'éteint",
"tolerance": "Tolérance, pour éviter que le thermostat ne s'allume et s'éteigne trop souvent.",
"window_off_delay": "Délai avant que le thermostat ne s'éteigne lorsque la fenêtre est ouverte",
"window_off_delay_after": "Délai avant que le thermostat ne s'allume lorsque la fenêtre est fermée",
"outdoor_sensor": "Si vous avez un capteur extérieur, vous pouvez l'utiliser pour obtenir la température extérieure",
"valve_maintenance": "Si votre thermostat n'a pas de mode de maintenance intégré, vous pouvez utiliser celui-ci",
"calibration": "Le type d'étalonnage https://better-thermostat.org/configuration#second-step",
"valve_maintenance": "Si votre thermostat n'a pas de mode maintenance propre, vous pouvez utiliser celui-ci",
"calibration": "Le type de calibrage https://better-thermostat.org/configuration#second-step",
"weather": "Votre entité météo pour obtenir la température extérieure",
"heat_auto_swapped": "Si auto signifie chauffer pour votre TRV et que vous voulez l'inverser",
"child_lock": "Ignorer toutes les entrées sur le TRV comme un vérouillage enfant",
"homaticip": "Si vous utilisez HomaticIP, vous devez l'activer pour ralentir les requêtes afin d'éviter le cycle de service",
"name": "Nom",
"thermostat": "Le vrai thermostat"
"heat_auto_swapped": "Si le mode automatique signifie chauffage pour votre TRV et que vous voulez le permuter",
"child_lock": "Ignorer toutes les entrées sur le TRV comme une sécurité enfant",
"homematicip": "Si vous utilisez HomematicIP, vous devriez l'activer pour ralentir les demandes et éviter le cycle de fonctionnement excessif"
}
},
"advanced": {
"description": "Configuration avancée {trv}\n\n***Informations sur les types d'étalonnage : https://better-thermostat.org/configuration#second-step***",
"description": "Configuration avancée {trv}\n***Informations sur les types de calibrage : https://better-thermostat.org/configuration#second-step***",
"data": {
"heat_auto_swapped": "Si auto signifie chauffer pour votre TRV et que vous voulez l'inverser",
"child_lock": "Ignorer toutes les entrées sur le TRV comme un vérouillage enfant",
"homaticip": "Si vous utilisez HomaticIP, vous devez l'activer pour ralentir les requêtes afin d'éviter le cycle de service",
"valve_maintenance": "Si votre thermostat n'a pas de mode de maintenance intégré, vous pouvez utiliser celui-ci",
"calibration": "The sort of calibration",
"protect_overheating": "Protection contre la surchauffe ?",
"calibration_mode": "Mode d'étalonnage",
"no_off_system_mode": "Si votre TRV ne peut pas gérer le mode arrêt, vous pouvez l'activer pour utiliser la température cible de 5 °C à la place"
"heat_auto_swapped": "Si le mode automatique signifie chauffage pour votre TRV et que vous voulez le permuter",
"child_lock": "Ignorer toutes les entrées sur le TRV comme une sécurité enfant",
"homematicip": "Si vous utilisez HomematicIP, vous devriez l'activer pour ralentir les demandes et éviter le cycle de fonctionnement excessif",
"valve_maintenance": "Si votre thermostat n'a pas de mode maintenance propre, vous pouvez utiliser celui-ci",
"calibration": "Le type de calibrage que vous souhaitez utiliser",
"calibration_mode": "Mode de calibrage",
"no_off_system_mode": "Si votre TRV ne peut pas gérer le mode hors tension, vous pouvez activer ceci pour utiliser une température cible de 5°C à la place"
},
"data_description": {
"protect_overheating": "Certaines VTR ferment complètement la vanne lorsque la température est atteinte. Ou le radiateur a beaucoup de chaleur résiduelle. Cela peut provoquer une surchauffe. Cette option peut empêcher cela.",
"calibration_mode": "Le type de calcul de l'étalonnage\n***Normal*** : Dans ce mode, le capteur de température interne TRV est fixé par le capteur de température externe.\n***Agressif*** : dans ce mode, le capteur de température interne TRV est fixé par le capteur de température externe mais réglé beaucoup plus bas/plus haut pour obtenir une accélération plus rapide.\n*** AI Time Based *** : Dans ce mode, le capteur de température interne TRV est fixé par le capteur de température externe, mais la valeur est calculée par un algorithme personnalisé pour améliorer l'algorithme interne TRV.",
"calibration": "Comment l'étalonnage doit être appliqué sur la VTR (température cible ou décalage)\n***Basé sur la température cible*** : Appliquez l'étalonnage à la température cible.\n***Basé sur le décalage*** : Appliquez l'étalonnage au décalage."
"protect_overheating": "Certains TRVs ne ferment pas complètement la vanne lorsque la température est atteinte. Ou le radiateur a beaucoup de chaleur résiduelle. Cela peut provoquer une surchauffe. Cette option peut prévenir cela.",
"calibration_mode": "Le type de calcul du calibrage\n***Normal*** : Dans ce mode, le capteur de température interne du TRV est fixé par le capteur de température externe.\n***Aggressif*** : Dans ce mode, le capteur de température interne du TRV est fixé par le capteur de température externe mais réglé beaucoup plus bas/haut pour obtenir un boost plus rapide.\n***AI Time Based*** : Dans ce mode, le capteur de température interne du TRV est fixé par le capteur de température externe, mais la valeur est calculée par un algorithme personnalisé pour améliorer l'algorithme interne du TRV.",
"calibration": "Comment le calibrage doit être appliqué sur le TRV (Température cible ou décalage)\n***Basé sur la température cible*** : Appliquer le calibrage à la température cible.\n***Basé sur le décalage*** : Appliquer le calibrage au décalage."
}
}
}
},
"issues": {
"missing_entity": {
"title": "BT : {name} - l'entité associée est manquante",
"title": "BT: {name} - l'entité associée est manquante",
"fix_flow": {
"step": {
"confirm": {
"title": "L'entité associée est {entity} manquante",
"description": "La raison en est que l'entité ({entity}) n'est pas disponible dans votre Home Assistant.\n\nVous pouvez résoudre ce problème en vérifiant si la batterie de l'appareil est pleine ou en le reconnectant à HA. Assurez-vous que l'entité est de retour dans HA avant de continuer."
"title": "L'entité associée {entity} est manquante",
"description": "La raison en est que l'entité ({entity}) n'est pas disponible dans votre Home Assistant.\n\nVous pouvez résoudre ce problème en vérifiant si la batterie du dispositif est pleine ou en le reconnectant à HA. Assurez-vous que l'entité est de retour dans HA avant de continuer."
}
}
}
}
},
"services": {
"save_current_target_temperature": {
"name": "Enregistrer la température actuelle",
"description": "Enregistrez la température cible actuelle pour une restauration ultérieure."
},
"restore_saved_target_temperature": {
"name": "Restaurer la température",
"description": "Restaurez la température cible enregistrée."
},
"reset_heating_power": {
"name": "Réinitialiser la puissance de chauffage",
"description": "Réinitialisez la puissance de chauffage à la valeur par défaut."
},
"set_temp_target_temperature": {
"name": "Définir la température éco",
"description": "Définissez la température cible sur une valeur temporaire comme le mode nuit, et enregistrez l'ancienne."
}
}
}
@@ -3,7 +3,7 @@
"config": {
"step": {
"user": {
"description": "Skonfiguruj swój Better Thermostat do integracji z Home Assistant\n**Więcej informacji znajdziesz na: https://better-thermostat.org/configuration#first-step** ",
"description": "Skonfiguruj swój Better Thermostat do integracji z Home Assistant\n**Więcej informacji znajdziesz na: https://better-thermostat.org/configuration#first-step**",
"data": {
"name": "Nazwa",
"thermostat": "Twój termostat",
@@ -17,11 +17,11 @@
}
},
"advanced": {
"description": "Zaawansowana konfiguracja\n\n**Informacja o typach kalibracji: https://better-thermostat.org/configuration#second-step** ",
"description": "Zaawansowana konfiguracja {trv}\n***Informacja o typach kalibracji: https://better-thermostat.org/configuration#second-step***",
"data": {
"heat_auto_swapped": "Jeżeli tryb auto oznacza grzanie dla Twojego TRV i chcesz to zmienić",
"child_lock": "Ignoruj wszystkie wejścia w TRV jak np. Blokada dziecięca",
"homaticip": "Jeżeli używasz HomaticIP, powinieneś włączyć tę opcję, żeby spowolnić żądania cyklu pracy",
"homematicip": "Jeżeli używasz HomematicIP, powinieneś włączyć tę opcję, żeby spowolnić żądania cyklu pracy",
"valve_maintenance": "Jeżeli Twój termostat nie ma trybu konserwacji, możesz użyć tej opcji.",
"calibration": "Rodzaj kalibracji, której chcesz użyć",
"no_off_system_mode": "Jeśli Twój TRV nie obsługuje trybu wyłączenia, możesz go włączyć, aby zamiast tego używać temperatury docelowej 5°C",
@@ -40,7 +40,7 @@
}
},
"error": {
"no_outside_temp": "Nie masz czujnika temperatury zewnętrznej. Lepszy termostat zamiast tego użyje jednostki pogodowej.",
"no_outside_temp": "Nie masz czujnika temperatury zewnętrznej. Better Thermostat zamiast tego użyje jednostki pogodowej.",
"failed": "coś poszło nie tak.",
"no_name": "Proszę podać nazwę.",
"no_off_mode": "Twoje urządzenie jest inne i nie ma trybu wyłączenia :(\nTo będzie działać, ale musisz stworzyć automatyzację, aby ustawić funkcje pod swoje urządzenie"
@@ -66,17 +66,17 @@
"weather": "Twoja jednostka pogodowa, aby uzyskać temperaturę zewnętrzną",
"heat_auto_swapped": "Jeżeli tryb auto oznacza grzanie dla Twojego TRV i chcesz to zmienić",
"child_lock": "Ignoruj wszystkie wejścia w TRV jak np. Blokada dziecięca",
"homaticip": "Jeżeli używasz HomaticIP, powinienieś włączyć tę opcję żeby spowolnić żądania",
"homematicip": "Jeżeli używasz HomematicIP, powinienieś włączyć tę opcję żeby spowolnić żądania",
"name": "Nazwa",
"thermostat": "Prawdziwy termostat"
}
},
"advanced": {
"description": "Zaawansowana konfiguracja**Informacja o typach kalibracji: https://better-thermostat.org/configuration#second-step** ",
"description": "Zaawansowana konfiguracja {trv}\n***Informacja o typach kalibracji: https://better-thermostat.org/configuration#second-step***",
"data": {
"heat_auto_swapped": "Jeżeli tryb auto oznacza grzanie dla Twojego TRV i chcesz to zmienić",
"child_lock": "Ignoruj wszystkie wejścia w TRV jak np. Blokada dziecięca",
"homaticip": "Jeżeli używasz HomaticIP, powinieneś włączyć tę opcję, żeby spowolnić żądania spowolnienia cyklu pracy",
"homematicip": "Jeżeli używasz HomematicIP, powinieneś włączyć tę opcję, żeby spowolnić żądania spowolnienia cyklu pracy",
"valve_maintenance": "Jeżeli Twój termostat nie ma trybu konserwacji, możesz użyć tej opcji.",
"calibration": "Rodzaj kalibracji, której chcesz użyć",
"protect_overheating": "Zabezpieczenie przed przegrzaniem?",
@@ -104,4 +104,4 @@
}
}
}
}
}
@@ -3,10 +3,11 @@
"config": {
"step": {
"user": {
"description": "Nastavenie lepšieho termostatu na integráciu s aplikáciou Home Assistant\n**Ak potrebujete viac informácií: https://better-thermostat.org/configuration#first-step** ",
"description": "Nastavenie lepšieho termostatu na integráciu s aplikáciou Home Assistant\n**Ak potrebujete viac informácií: https://better-thermostat.org/configuration#first-step**",
"data": {
"name": "Názov",
"thermostat": "Skutočný termostat",
"cooler": "Chladiace zariadenie (voliteľné)",
"temperature_sensor": "Snímač teploty",
"humidity_sensor": "Snímač vlhkosti",
"window_sensors": "Snímač okna",
@@ -24,7 +25,7 @@
"protect_overheating": "Ochrana proti prehriatiu?",
"heat_auto_swapped": "Ak auto znamená teplo pre váš TRV a chcete ho vymeniť",
"child_lock": "Ignorovať všetky vstupy na TRV ako detský zámok",
"homaticip": "Ak používate HomaticIP, mali by ste túto funkciu povoliť, aby ste spomalili požiadavky a zabránili tak pracovnému cyklu",
"homematicip": "Ak používate HomematicIP, mali by ste túto funkciu povoliť, aby ste spomalili požiadavky a zabránili tak pracovnému cyklu",
"valve_maintenance": "Ak váš termostat nemá vlastný režim údržby, môžete použiť tento režim",
"calibration": "Typ kalibrácie",
"calibration_mode": "Režim kalibrácie",
@@ -72,7 +73,7 @@
"weather": "Váš meteorologický subjekt na zistenie vonkajšej teploty",
"heat_auto_swapped": "Ak auto znamená teplo pre váš TRV a chcete ho vymeniť",
"child_lock": "Ignorovať všetky vstupy na TRV ako detský zámok",
"homaticip": "Ak používate HomaticIP, mali by ste túto funkciu povoliť, aby ste spomalili požiadavky a zabránili tak pracovnému cyklu"
"homematicip": "Ak používate HomematicIP, mali by ste túto funkciu povoliť, aby ste spomalili požiadavky a zabránili tak pracovnému cyklu"
}
},
"advanced": {
@@ -81,7 +82,7 @@
"protect_overheating": "Ochrana proti prehriatiu?",
"heat_auto_swapped": "Ak auto znamená teplo pre váš TRV a chcete ho vymeniť",
"child_lock": "Ignorovať všetky vstupy na TRV ako detský zámok",
"homaticip": "Ak používate HomaticIP, mali by ste túto funkciu povoliť, aby ste spomalili požiadavky a zabránili tak pracovnému cyklu",
"homematicip": "Ak používate HomematicIP, mali by ste túto funkciu povoliť, aby ste spomalili požiadavky a zabránili tak pracovnému cyklu",
"valve_maintenance": "Ak váš termostat nemá vlastný režim údržby, môžete použiť tento režim",
"calibration": "Druh kalibrácie, ktorý chcete použiť",
"calibration_mode": "Režim kalibrácie",
@@ -126,4 +127,4 @@
"description": "Nastavte cieľovú teplotu na dočasný, napríklad nočný režim, a uložte starú teplotu."
}
}
}
}
@@ -1,98 +0,0 @@
from importlib import import_module
import logging
_LOGGER = logging.getLogger(__name__)
def load_adapter(self, integration, entity_id, get_name=False):
"""Load adapter."""
if get_name:
self.name = "-"
if integration == "generic_thermostat":
integration = "generic"
try:
self.adapter = import_module(
"custom_components.better_thermostat.adapters." + integration,
package="better_thermostat",
)
_LOGGER.debug(
"better_thermostat %s: uses adapter %s for trv %s",
self.name,
integration,
entity_id,
)
except Exception:
self.adapter = import_module(
"custom_components.better_thermostat.adapters.generic",
package="better_thermostat",
)
_LOGGER.info(
"better_thermostat %s: intigration: %s isn't native supported, feel free to open an issue, fallback adapter %s",
self.name,
integration,
"generic",
)
pass
if get_name:
return integration
return self.adapter
async def init(self, entity_id):
"""Init adapter."""
return await self.real_trvs[entity_id]["adapter"].init(self, entity_id)
async def get_info(self, entity_id):
return await self.real_trvs[entity_id]["adapter"].get_info(self, entity_id)
async def get_current_offset(self, entity_id):
"""Get current offset."""
return await self.real_trvs[entity_id]["adapter"].get_current_offset(
self, entity_id
)
async def get_offset_steps(self, entity_id):
"""get offset setps."""
return await self.real_trvs[entity_id]["adapter"].get_offset_steps(self, entity_id)
async def get_min_offset(self, entity_id):
"""Get min offset."""
return await self.real_trvs[entity_id]["adapter"].get_min_offset(self, entity_id)
async def get_max_offset(self, entity_id):
"""Get max offset."""
return await self.real_trvs[entity_id]["adapter"].get_max_offset(self, entity_id)
async def set_temperature(self, entity_id, temperature):
"""Set new target temperature."""
return await self.real_trvs[entity_id]["adapter"].set_temperature(
self, entity_id, temperature
)
async def set_hvac_mode(self, entity_id, hvac_mode):
"""Set new target hvac mode."""
return await self.real_trvs[entity_id]["adapter"].set_hvac_mode(
self, entity_id, hvac_mode
)
async def set_offset(self, entity_id, offset):
"""Set new target offset."""
return await self.real_trvs[entity_id]["adapter"].set_offset(
self, entity_id, offset
)
async def set_valve(self, entity_id, valve):
"""Set new target valve."""
return await self.real_trvs[entity_id]["adapter"].set_valve(self, entity_id, valve)
@@ -1,26 +1,44 @@
import asyncio
import logging
from custom_components.better_thermostat.utils.model_quirks import (
from homeassistant.components.climate.const import HVACMode
from custom_components.better_thermostat.model_fixes.model_quirks import (
override_set_hvac_mode,
override_set_temperature,
)
from .bridge import (
from custom_components.better_thermostat.adapters.delegate import (
set_offset,
get_current_offset,
get_offset_steps,
set_temperature,
set_hvac_mode,
)
from ..events.trv import convert_outbound_states, update_hvac_action
from homeassistant.components.climate.const import HVACMode
from .helpers import convert_to_float, calibration_round
from custom_components.better_thermostat.events.trv import (
convert_outbound_states,
update_hvac_action,
)
from custom_components.better_thermostat.utils.helpers import convert_to_float
from custom_components.better_thermostat.utils.const import CalibrationMode
_LOGGER = logging.getLogger(__name__)
class TaskManager:
def __init__(self):
self.tasks = set()
def create_task(self, coro):
task = asyncio.create_task(coro)
self.tasks.add(task)
task.add_done_callback(self.tasks.discard)
return task
async def control_queue(self):
"""The accutal control loop.
Parameters
@@ -32,6 +50,9 @@ async def control_queue(self):
-------
None
"""
if not hasattr(self, "task_manager"):
self.task_manager = TaskManager()
while True:
if self.ignore_states or self.startup_running:
await asyncio.sleep(1)
@@ -49,7 +70,7 @@ async def control_queue(self):
except Exception:
_LOGGER.exception(
"better_thermostat %s: ERROR controlling: %s",
self.name,
self.device_name,
trv,
)
result = False
@@ -80,13 +101,18 @@ async def control_trv(self, heater_entity_id=None):
-------
None
"""
if not hasattr(self, "task_manager"):
self.task_manager = TaskManager()
async with self._temp_lock:
self.real_trvs[heater_entity_id]["ignore_trv_states"] = True
await update_hvac_action(self)
await self.calculate_heating_power()
_trv = self.hass.states.get(heater_entity_id)
_current_set_temperature = convert_to_float(
str(_trv.attributes.get("temperature", None)), self.name, "controlling()"
str(_trv.attributes.get("temperature", None)),
self.device_name,
"controlling()",
)
_remapped_states = convert_outbound_states(
@@ -94,7 +120,7 @@ async def control_trv(self, heater_entity_id=None):
)
if not isinstance(_remapped_states, dict):
_LOGGER.debug(
f"better_thermostat {self.name}: ERROR {heater_entity_id} {_remapped_states}"
f"better_thermostat {self.device_name}: ERROR {heater_entity_id} {_remapped_states}"
)
await asyncio.sleep(10)
self.ignore_states = False
@@ -103,6 +129,9 @@ async def control_trv(self, heater_entity_id=None):
_temperature = _remapped_states.get("temperature", None)
_calibration = _remapped_states.get("local_temperature_calibration", None)
_calibration_mode = self.real_trvs[heater_entity_id]["advanced"].get(
"calibration_mode", CalibrationMode.DEFAULT
)
_new_hvac_mode = handle_window_open(self, _remapped_states)
@@ -131,7 +160,7 @@ async def control_trv(self, heater_entity_id=None):
context=self.context,
)
elif (
self.cur_temp < self.bt_target_cooltemp - self.tolerance
self.cur_temp <= (self.bt_target_cooltemp - self.tolerance)
and _new_hvac_mode is not HVACMode.OFF
):
await self.hass.services.async_call(
@@ -186,7 +215,7 @@ async def control_trv(self, heater_entity_id=None):
if _no_off_system_mode is True and _new_hvac_mode == HVACMode.OFF:
_min_temp = self.real_trvs[heater_entity_id]["min_temp"]
_LOGGER.debug(
f"better_thermostat {self.name}: sending {_min_temp}°C to the TRV because this device has no system mode off and heater should be off"
f"better_thermostat {self.device_name}: sending {_min_temp}°C to the TRV because this device has no system mode off and heater should be off"
)
_temperature = _min_temp
@@ -200,7 +229,7 @@ async def control_trv(self, heater_entity_id=None):
)
):
_LOGGER.debug(
f"better_thermostat {self.name}: TO TRV set_hvac_mode: {heater_entity_id} from: {_trv.state} to: {_new_hvac_mode}"
f"better_thermostat {self.device_name}: TO TRV set_hvac_mode: {heater_entity_id} from: {_trv.state} to: {_new_hvac_mode}"
)
self.real_trvs[heater_entity_id]["last_hvac_mode"] = _new_hvac_mode
_tvr_has_quirk = await override_set_hvac_mode(
@@ -210,55 +239,54 @@ async def control_trv(self, heater_entity_id=None):
await set_hvac_mode(self, heater_entity_id, _new_hvac_mode)
if self.real_trvs[heater_entity_id]["system_mode_received"] is True:
self.real_trvs[heater_entity_id]["system_mode_received"] = False
asyncio.create_task(check_system_mode(self, heater_entity_id))
self.task_manager.create_task(check_system_mode(self, heater_entity_id))
# set new calibration offset
if _calibration is not None and _new_hvac_mode != HVACMode.OFF:
old_calibration = await get_current_offset(self, heater_entity_id)
step_calibration = await get_offset_steps(self, heater_entity_id)
if old_calibration is None or step_calibration is None:
if (
_calibration is not None
and _new_hvac_mode != HVACMode.OFF
and _calibration_mode != CalibrationMode.NO_CALIBRATION
):
_current_calibration_s = await get_current_offset(self, heater_entity_id)
if _current_calibration_s is None:
_LOGGER.error(
"better_thermostat %s: calibration fatal error %s",
self.name,
self.device_name,
heater_entity_id,
)
# this should not be before, set_hvac_mode (because if it fails, the new hvac mode will never be sent)
self.ignore_states = False
self.real_trvs[heater_entity_id]["ignore_trv_states"] = False
return True
current_calibration = convert_to_float(
str(old_calibration), self.name, "controlling()"
)
if step_calibration.is_integer():
_calibration = calibration_round(
float(str(format(float(_calibration), ".1f")))
)
else:
_calibration = float(str(format(float(_calibration), ".1f")))
old = self.real_trvs[heater_entity_id].get(
"last_calibration", current_calibration
_current_calibration = convert_to_float(
str(_current_calibration_s), self.device_name, "controlling()"
)
_calibration = float(str(_calibration))
_old_calibration = self.real_trvs[heater_entity_id].get(
"last_calibration", _current_calibration
)
if self.real_trvs[heater_entity_id][
"calibration_received"
] is True and float(old) != float(_calibration):
] is True and float(_old_calibration) != float(_calibration):
_LOGGER.debug(
f"better_thermostat {self.name}: TO TRV set_local_temperature_calibration: {heater_entity_id} from: {old} to: {_calibration}"
f"better_thermostat {self.device_name}: TO TRV set_local_temperature_calibration: {heater_entity_id} from: {_old_calibration} to: {_calibration}"
)
await set_offset(self, heater_entity_id, _calibration)
self.real_trvs[heater_entity_id]["calibration_received"] = False
# set new target temperature
if (
_temperature is not None
and _new_hvac_mode != HVACMode.OFF
or _no_off_system_mode
if _temperature is not None and (
_new_hvac_mode != HVACMode.OFF or _no_off_system_mode
):
if _temperature != _current_set_temperature:
old = self.real_trvs[heater_entity_id].get("last_temperature", "?")
_LOGGER.debug(
f"better_thermostat {self.name}: TO TRV set_temperature: {heater_entity_id} from: {old} to: {_temperature}"
f"better_thermostat {self.device_name}: TO TRV set_temperature: {heater_entity_id} from: {old} to: {_temperature}"
)
self.real_trvs[heater_entity_id]["last_temperature"] = _temperature
_tvr_has_quirk = await override_set_temperature(
@@ -268,7 +296,7 @@ async def control_trv(self, heater_entity_id=None):
await set_temperature(self, heater_entity_id, _temperature)
if self.real_trvs[heater_entity_id]["target_temp_received"] is True:
self.real_trvs[heater_entity_id]["target_temp_received"] = False
asyncio.create_task(
self.task_manager.create_task(
check_target_temperature(self, heater_entity_id)
)
@@ -288,13 +316,13 @@ def handle_window_open(self, _remapped_states):
_hvac_mode_send = HVACMode.OFF
self.last_window_state = True
_LOGGER.debug(
f"better_thermostat {self.name}: control_trv: window is open or status of window is unknown, setting window open"
f"better_thermostat {self.device_name}: control_trv: window is open or status of window is unknown, setting window open"
)
elif self.window_open is False and self.last_window_state is True:
_hvac_mode_send = self.last_main_hvac_mode
self.last_window_state = False
_LOGGER.debug(
f"better_thermostat {self.name}: control_trv: window is closed, setting window closed restoring mode: {_hvac_mode_send}"
f"better_thermostat {self.device_name}: control_trv: window is closed, setting window closed restoring mode: {_hvac_mode_send}"
)
# Force off on window open
@@ -311,7 +339,7 @@ async def check_system_mode(self, heater_entity_id=None):
while _real_trv["hvac_mode"] != _real_trv["last_hvac_mode"]:
if _timeout > 360:
_LOGGER.debug(
f"better_thermostat {self.name}: {heater_entity_id} the real TRV did not respond to the system mode change"
f"better_thermostat {self.device_name}: {heater_entity_id} the real TRV did not respond to the system mode change"
)
_timeout = 0
break
@@ -333,12 +361,12 @@ async def check_target_temperature(self, heater_entity_id=None):
"temperature", None
)
),
self.name,
self.device_name,
"check_target_temperature()",
)
if _timeout == 0:
_LOGGER.debug(
f"better_thermostat {self.name}: {heater_entity_id} / check_target_temp / _last: {_real_trv['last_temperature']} - _current: {_current_set_temperature}"
f"better_thermostat {self.device_name}: {heater_entity_id} / check_target_temp / _last: {_real_trv['last_temperature']} - _current: {_current_set_temperature}"
)
if (
_current_set_temperature is None
@@ -346,9 +374,9 @@ async def check_target_temperature(self, heater_entity_id=None):
):
_timeout = 0
break
if _timeout > 120:
if _timeout > 360:
_LOGGER.debug(
f"better_thermostat {self.name}: {heater_entity_id} the real TRV did not respond to the target temperature change"
f"better_thermostat {self.device_name}: {heater_entity_id} the real TRV did not respond to the target temperature change"
)
_timeout = 0
break
@@ -1,25 +1,16 @@
"""Helper functions for the Better Thermostat component."""
import re
import logging
import math
from datetime import datetime
from typing import Union
from enum import Enum
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.entity_registry import async_entries_for_config_entry
from homeassistant.components.climate.const import HVACMode, HVACAction
from homeassistant.components.climate.const import HVACMode
from custom_components.better_thermostat.utils.model_quirks import (
fix_local_calibration,
fix_target_temperature_calibration,
)
from ..const import (
CONF_HEAT_AUTO_SWAPPED,
CONF_HEATING_POWER_CALIBRATION,
CONF_FIX_CALIBRATION,
CONF_PROTECT_OVERHEATING,
)
from custom_components.better_thermostat.utils.const import CONF_HEAT_AUTO_SWAPPED
_LOGGER = logging.getLogger(__name__)
@@ -56,239 +47,80 @@ def mode_remap(self, entity_id, hvac_mode: str, inbound: bool = False) -> str:
)
if _heat_auto_swapped:
if hvac_mode == HVACMode.HEAT and inbound is False:
if hvac_mode == HVACMode.HEAT and not inbound:
return HVACMode.AUTO
elif hvac_mode == HVACMode.AUTO and inbound is True:
if hvac_mode == HVACMode.AUTO and inbound:
return HVACMode.HEAT
else:
return hvac_mode
else:
if hvac_mode != HVACMode.AUTO:
return hvac_mode
else:
_LOGGER.error(
f"better_thermostat {self.name}: {entity_id} HVAC mode {hvac_mode} is not supported by this device, is it possible that you forgot to set the heat auto swapped option?"
)
return HVACMode.OFF
return hvac_mode
trv_modes = self.real_trvs[entity_id]["hvac_modes"]
if HVACMode.HEAT not in trv_modes and HVACMode.HEAT_COOL in trv_modes:
# entity only supports HEAT_COOL, but not HEAT - need to translate
if not inbound and hvac_mode == HVACMode.HEAT:
return HVACMode.HEAT_COOL
if inbound and hvac_mode == HVACMode.HEAT_COOL:
return HVACMode.HEAT
def calculate_local_setpoint_delta(self, entity_id) -> Union[float, None]:
"""Calculate local delta to adjust the setpoint of the TRV based on the air temperature of the external sensor.
if hvac_mode != HVACMode.AUTO:
return hvac_mode
This calibration is for devices with local calibration option, it syncs the current temperature of the TRV to the target temperature of
the external sensor.
Parameters
----------
self :
self instance of better_thermostat
Returns
-------
float
new local calibration delta
"""
_context = "calculate_local_setpoint_delta()"
if None in (self.cur_temp, self.bt_target_temp, self.old_internal_temp):
return None
# check if we need to calculate
if (
self.real_trvs[entity_id]["current_temperature"] == self.old_internal_temp
and self.cur_temp == self.old_external_temp
):
return None
_cur_trv_temp = convert_to_float(
str(self.real_trvs[entity_id]["current_temperature"]), self.name, _context
_LOGGER.error(
f"better_thermostat {self.device_name}: {entity_id} HVAC mode {
hvac_mode} is not supported by this device, is it possible that you forgot to set the heat auto swapped option?"
)
_calibration_delta = float(
str(format(float(abs(_cur_trv_temp - self.cur_temp)), ".1f"))
)
if _calibration_delta <= 0.5:
return None
self.old_internal_temp = self.real_trvs[entity_id]["current_temperature"]
self.old_external_temp = self.cur_temp
_current_trv_calibration = round_to_half_degree(
convert_to_float(
str(self.real_trvs[entity_id]["last_calibration"]), self.name, _context
)
)
if None in (_current_trv_calibration, self.cur_temp, _cur_trv_temp):
_LOGGER.warning(
f"better thermostat {self.name}: {entity_id} Could not calculate local setpoint delta in {_context}:"
f" current_trv_calibration: {_current_trv_calibration}, current_trv_temp: {_cur_trv_temp}, cur_temp: {self.cur_temp}"
)
return None
_new_local_calibration = (self.cur_temp - _cur_trv_temp) + _current_trv_calibration
_calibration_mode = self.real_trvs[entity_id]["advanced"].get(
"calibration_mode", "default"
)
if _calibration_mode == CONF_FIX_CALIBRATION:
# _temp_diff = float(float(self.bt_target_temp) - float(self.cur_temp))
# if _temp_diff > 0.30 and _new_local_calibration > -2.5:
# _new_local_calibration -= 2.5
if self.attr_hvac_action == HVACAction.HEATING:
if _new_local_calibration > -2.5:
_new_local_calibration -= 2.5
elif _calibration_mode == CONF_HEATING_POWER_CALIBRATION:
# _temp_diff = float(float(self.bt_target_temp) - float(self.cur_temp))
# if _temp_diff > 0.0:
# valve_position = heating_power_valve_position(self, entity_id)
# _new_local_calibration = _current_trv_calibration - (
# (self.real_trvs[entity_id]["local_calibration_min"] + _cur_trv_temp)
# * valve_position
# )
if self.attr_hvac_action == HVACAction.HEATING:
# if _temp_diff > 0.0:
valve_position = heating_power_valve_position(self, entity_id)
_new_local_calibration = _current_trv_calibration - (
(self.real_trvs[entity_id]["local_calibration_min"] + _cur_trv_temp)
* valve_position
)
elif self.attr_hvac_action == HVACAction.IDLE:
if _new_local_calibration < 0.0:
_new_local_calibration += self.tolerance
_new_local_calibration = fix_local_calibration(
self, entity_id, _new_local_calibration
)
_overheating_protection = self.real_trvs[entity_id]["advanced"].get(
CONF_PROTECT_OVERHEATING, False
)
if _overheating_protection is True:
if self.cur_temp >= self.bt_target_temp:
_new_local_calibration += (self.cur_temp - self.bt_target_temp) * 10.0
_new_local_calibration = round_down_to_half_degree(_new_local_calibration)
if _new_local_calibration > float(
self.real_trvs[entity_id]["local_calibration_max"]
):
_new_local_calibration = float(
self.real_trvs[entity_id]["local_calibration_max"]
)
elif _new_local_calibration < float(
self.real_trvs[entity_id]["local_calibration_min"]
):
_new_local_calibration = float(
self.real_trvs[entity_id]["local_calibration_min"]
)
_new_local_calibration = convert_to_float(
str(_new_local_calibration), self.name, _context
)
_LOGGER.debug(
"better_thermostat %s: %s - output calib: %s",
self.name,
entity_id,
_new_local_calibration,
)
return convert_to_float(str(_new_local_calibration), self.name, _context)
def calculate_setpoint_override(self, entity_id) -> Union[float, None]:
"""Calculate new setpoint for the TRV based on its own temperature measurement and the air temperature of the external sensor.
This calibration is for devices with no local calibration option, it syncs the target temperature of the TRV to a new target
temperature based on the current temperature of the external sensor.
Parameters
----------
self :
self instance of better_thermostat
Returns
-------
float
new target temp with calibration
"""
if None in (self.cur_temp, self.bt_target_temp):
return None
_cur_trv_temp = self.hass.states.get(entity_id).attributes["current_temperature"]
if None in (self.bt_target_temp, self.cur_temp, _cur_trv_temp):
return None
_calibrated_setpoint = (self.bt_target_temp - self.cur_temp) + _cur_trv_temp
_calibration_mode = self.real_trvs[entity_id]["advanced"].get(
"calibration_mode", "default"
)
if _calibration_mode == CONF_FIX_CALIBRATION:
# _temp_diff = float(float(self.bt_target_temp) - float(self.cur_temp))
if self.attr_hvac_action == HVACAction.HEATING:
if _calibrated_setpoint - _cur_trv_temp < 2.5:
_calibrated_setpoint += 2.5
elif self.attr_hvac_action == HVACAction.IDLE:
if _calibrated_setpoint - _cur_trv_temp > 0.0:
_calibrated_setpoint -= self.tolerance
elif _calibration_mode == CONF_HEATING_POWER_CALIBRATION:
# _temp_diff = float(float(self.bt_target_temp) - float(self.cur_temp))
if self.attr_hvac_action == HVACAction.HEATING:
# if _temp_diff > 0.0:
valve_position = heating_power_valve_position(self, entity_id)
_calibrated_setpoint = _cur_trv_temp + (
(self.real_trvs[entity_id]["max_temp"] - _cur_trv_temp) * valve_position
)
elif self.attr_hvac_action == HVACAction.IDLE:
if _calibrated_setpoint - _cur_trv_temp > 0.0:
_calibrated_setpoint -= self.tolerance
_calibrated_setpoint = fix_target_temperature_calibration(
self, entity_id, _calibrated_setpoint
)
_overheating_protection = self.real_trvs[entity_id]["advanced"].get(
CONF_PROTECT_OVERHEATING, False
)
if _overheating_protection is True:
if self.cur_temp >= self.bt_target_temp:
_calibrated_setpoint -= (self.cur_temp - self.bt_target_temp) * 10.0
_calibrated_setpoint = round_down_to_half_degree(_calibrated_setpoint)
# check if new setpoint is inside the TRV's range, else set to min or max
if _calibrated_setpoint < self.real_trvs[entity_id]["min_temp"]:
_calibrated_setpoint = self.real_trvs[entity_id]["min_temp"]
if _calibrated_setpoint > self.real_trvs[entity_id]["max_temp"]:
_calibrated_setpoint = self.real_trvs[entity_id]["max_temp"]
return _calibrated_setpoint
return HVACMode.OFF
def heating_power_valve_position(self, entity_id):
_temp_diff = float(float(self.bt_target_temp) - float(self.cur_temp))
valve_pos = (_temp_diff / self.heating_power) / 100
a = 0.019
b = 0.946
valve_pos = a * (_temp_diff / self.heating_power) ** b
if valve_pos < 0.0:
valve_pos = 0.0
if valve_pos > 1.0:
valve_pos = 1.0
_LOGGER.debug(
f"better_thermostat {self.name}: {entity_id} / heating_power_valve_position - temp diff: {round(_temp_diff, 1)} - heating power: {round(self.heating_power, 4)} - expected valve position: {round(valve_pos * 100)}%"
f"better_thermostat {self.device_name}: {entity_id} / heating_power_valve_position - temp diff: {round(
_temp_diff, 1)} - heating power: {round(self.heating_power, 4)} - expected valve position: {round(valve_pos * 100)}%"
)
return valve_pos
# Example values for different heating_power and temp_diff:
# With heating_power of 0.02:
# | temp_diff | valve_pos |
# |-----------|------------|
# | 0.1 | 0.0871 |
# | 0.2 | 0.1678 |
# | 0.3 | 0.2462 |
# | 0.4 | 0.3232 |
# | 0.5 | 0.3992 |
# With heating_power of 0.01:
# | temp_diff | valve_pos |
# |-----------|------------|
# | 0.1 | 0.1678 |
# | 0.2 | 0.3232 |
# | 0.3 | 0.4744 |
# | 0.4 | 0.6227 |
# | 0.5 | 0.7691 |
# With heating_power of 0.005:
# | temp_diff | valve_pos |
# |-----------|------------|
# | 0.1 | 0.3232 |
# | 0.2 | 0.6227 |
# | 0.3 | 0.9139 |
# | 0.4 | 1.0000 |
# | 0.5 | 1.0000 |
def convert_to_float(
value: Union[str, int, float], instance_name: str, context: str
) -> Union[float, None]:
value: str | float, instance_name: str, context: str
) -> float | None:
"""Convert value to float or print error message.
Parameters
@@ -307,115 +139,53 @@ def convert_to_float(
None
If error occurred and cannot convert the value.
"""
if isinstance(value, float):
return round(value, 1)
elif value is None or value == "None":
if value is None or value == "None":
return None
try:
return round_by_step(float(value), 0.1)
except (ValueError, TypeError, AttributeError, KeyError):
_LOGGER.debug(
f"better thermostat {instance_name}: Could not convert '{
value}' to float in {context}"
)
return None
else:
try:
return round(float(str(format(float(value), ".1f"))), 1)
except (ValueError, TypeError, AttributeError, KeyError):
_LOGGER.debug(
f"better thermostat {instance_name}: Could not convert '{value}' to float in {context}"
)
return None
def calibration_round(value: Union[int, float, None]) -> Union[float, int, None]:
"""Round the calibration value to the nearest 0.5.
class rounding(Enum):
# rounding functions that avoid errors due to using floats
def up(x: float) -> float:
return math.ceil(x - 0.0001)
def down(x: float) -> float:
return math.floor(x + 0.0001)
def nearest(x: float) -> float:
return round(x - 0.0001)
def round_by_step(
value: float | None, step: float | None, f_rounding: rounding = rounding.nearest
) -> float | None:
"""Round the value based on the allowed decimal 'step' size.
Parameters
----------
value : float
the value to round
step : float
size of one step
Returns
-------
float
the rounded value
"""
if value is None:
if value is None or step is None:
return None
split = str(float(str(value))).split(".", 1)
decimale = int(split[1])
if decimale > 8:
return float(str(split[0])) + 1.0
else:
return float(str(split[0]))
def round_down_to_half_degree(
value: Union[int, float, None]
) -> Union[float, int, None]:
"""Round the value down to the nearest 0.5.
Parameters
----------
value : float
the value to round
Returns
-------
float
the rounded value
"""
if value is None:
return None
split = str(float(str(value))).split(".", 1)
decimale = int(split[1])
if decimale >= 5:
if float(split[0]) > 0:
return float(str(split[0])) + 0.5
else:
return float(str(split[0])) - 0.5
else:
return float(str(split[0]))
def round_to_half_degree(value: Union[int, float, None]) -> Union[float, int, None]:
"""Rounds numbers to the nearest n.5/n.0
Parameters
----------
value : int, float
input value
Returns
-------
float, int
either an int, if input was an int, or a float rounded to n.5/n.0
"""
if value is None:
return None
elif isinstance(value, float):
return round(value * 2) / 2
elif isinstance(value, int):
return value
def round_to_hundredth_degree(
value: Union[int, float, None]
) -> Union[float, int, None]:
"""Rounds numbers to the nearest n.nn0
Parameters
----------
value : int, float
input value
Returns
-------
float, int
either an int, if input was an int, or a float rounded to n.nn0
"""
if value is None:
return None
elif isinstance(value, float):
return round(value * 100) / 100
elif isinstance(value, int):
return value
# convert to integer number of steps for rounding, then convert back to decimal
return f_rounding(value / step) * step
def check_float(potential_float):
@@ -499,7 +269,8 @@ async def find_valve_entity(self, entity_id):
if entity.device_id == reg_entity.device_id:
if "_valve_position" in uid or "_position" in uid:
_LOGGER.debug(
f"better thermostat: Found valve position entity {entity.entity_id} for {entity_id}"
f"better thermostat: Found valve position entity {
entity.entity_id} for {entity_id}"
)
return entity.entity_id
@@ -561,7 +332,8 @@ async def find_local_calibration_entity(self, entity_id):
if entity.device_id == reg_entity.device_id:
if "temperature_calibration" in uid or "temperature_offset" in uid:
_LOGGER.debug(
f"better thermostat: Found local calibration entity {entity.entity_id} for {entity_id}"
f"better thermostat: Found local calibration entity {
entity.entity_id} for {entity_id}"
)
return entity.entity_id
@@ -635,12 +407,13 @@ async def get_device_model(self, entity_id):
entry = entity_reg.async_get(entity_id)
dev_reg = dr.async_get(self.hass)
device = dev_reg.async_get(entry.device_id)
_LOGGER.debug(f"better_thermostat {self.name}: found device:")
_LOGGER.debug(f"better_thermostat {self.device_name}: found device:")
_LOGGER.debug(device)
try:
# Z2M reports the device name as a long string with the actual model name in braces, we need to extract it
return re.search("\\((.+?)\\)", device.model).group(1)
except AttributeError:
matches = re.findall(r"\((.+?)\)", device.model)
return matches[-1]
except IndexError:
# Other climate integrations might report the model name plainly, need more infos on this
return device.model
except (
@@ -1,55 +0,0 @@
from importlib import import_module
import logging
_LOGGER = logging.getLogger(__name__)
def load_model_quirks(self, model, entity_id):
"""Load model."""
# remove / from model
model = model.replace("/", "_")
try:
self.model_quirks = import_module(
"custom_components.better_thermostat.model_fixes." + model,
package="better_thermostat",
)
_LOGGER.debug(
"better_thermostat %s: uses quirks fixes for model %s for trv %s",
self.name,
model,
entity_id,
)
except Exception:
self.model_quirks = import_module(
"custom_components.better_thermostat.model_fixes.default",
package="better_thermostat",
)
pass
return self.model_quirks
def fix_local_calibration(self, entity_id, offset):
return self.real_trvs[entity_id]["model_quirks"].fix_local_calibration(
self, entity_id, offset
)
def fix_target_temperature_calibration(self, entity_id, temperature):
return self.real_trvs[entity_id]["model_quirks"].fix_target_temperature_calibration(
self, entity_id, temperature
)
async def override_set_hvac_mode(self, entity_id, hvac_mode):
return await self.real_trvs[entity_id]["model_quirks"].override_set_hvac_mode(
self, entity_id, hvac_mode
)
async def override_set_temperature(self, entity_id, temperature):
return await self.real_trvs[entity_id]["model_quirks"].override_set_temperature(
self, entity_id, temperature
)
@@ -24,7 +24,7 @@ async def check_entity(self, entity) -> bool:
"unavailable",
):
_LOGGER.debug(
f"better_thermostat {self.name}: {entity} is unavailable. with state {state}"
f"better_thermostat {self.device_name}: {entity} is unavailable. with state {state}"
)
return False
if entity in self.devices_errors:
@@ -66,7 +66,10 @@ async def check_all_entities(self) -> bool:
learn_more_url="https://better-thermostat.org/qanda/missing_entity",
severity=ir.IssueSeverity.WARNING,
translation_key="missing_entity",
translation_placeholders={"entity": str(name), "name": str(self.name)},
translation_placeholders={
"entity": str(name),
"name": str(self.device_name),
},
)
return False
return True
@@ -17,7 +17,7 @@ from statistics import median
_LOGGER = logging.getLogger(__name__)
def check_weather(self) -> bool:
async def check_weather(self) -> bool:
"""check weather predictions or ambient air temperature if available
Parameters
@@ -37,7 +37,7 @@ def check_weather(self) -> bool:
self.call_for_heat = True
if self.weather_entity is not None:
_call_for_heat_weather = check_weather_prediction(self)
_call_for_heat_weather = await check_weather_prediction(self)
self.call_for_heat = _call_for_heat_weather
if self.outdoor_sensor is not None:
@@ -45,7 +45,7 @@ def check_weather(self) -> bool:
# TODO: add condition if heating period (oct-mar) then set it to true?
_LOGGER.warning(
"better_thermostat %s: no outdoor sensor data found. fallback to heat",
self.name,
self.device_name,
)
_call_for_heat_outdoor = True
else:
@@ -63,7 +63,7 @@ def check_weather(self) -> bool:
return False
def check_weather_prediction(self) -> bool:
async def check_weather_prediction(self) -> bool:
"""Checks configured weather entity for next two days of temperature predictions.
Returns
@@ -74,17 +74,26 @@ def check_weather_prediction(self) -> bool:
if not successful
"""
if self.weather_entity is None:
_LOGGER.warning(f"better_thermostat {self.name}: weather entity not available.")
_LOGGER.warning(
f"better_thermostat {self.device_name}: weather entity not available."
)
return None
if self.off_temperature is None or not isinstance(self.off_temperature, float):
_LOGGER.warning(
f"better_thermostat {self.name}: off_temperature not set or not a float."
f"better_thermostat {self.device_name}: off_temperature not set or not a float."
)
return None
try:
forecast = self.hass.states.get(self.weather_entity).attributes.get("forecast")
forecasts = await self.hass.services.async_call(
"weather",
"get_forecasts",
{"type": "daily", "entity_id": self.weather_entity},
blocking=True,
return_response=True,
)
forecast = forecasts.get(self.weather_entity).get("forecast")
if len(forecast) > 0:
cur_outside_temp = convert_to_float(
str(
@@ -92,7 +101,7 @@ def check_weather_prediction(self) -> bool:
"temperature"
)
),
self.name,
self.device_name,
"check_weather_prediction()",
)
max_forecast_temp = int(
@@ -100,12 +109,12 @@ def check_weather_prediction(self) -> bool:
(
convert_to_float(
str(forecast[0]["temperature"]),
self.name,
self.device_name,
"check_weather_prediction()",
)
+ convert_to_float(
str(forecast[1]["temperature"]),
self.name,
self.device_name,
"check_weather_prediction()",
)
)
@@ -119,7 +128,9 @@ def check_weather_prediction(self) -> bool:
else:
raise TypeError
except TypeError:
_LOGGER.warning(f"better_thermostat {self.name}: no weather entity data found.")
_LOGGER.warning(
f"better_thermostat {self.device_name}: no weather entity data found."
)
return None
@@ -138,13 +149,13 @@ async def check_ambient_air_temperature(self):
if self.off_temperature is None or not isinstance(self.off_temperature, float):
_LOGGER.warning(
f"better_thermostat {self.name}: off_temperature not set or not a float."
f"better_thermostat {self.device_name}: off_temperature not set or not a float."
)
return None
self.last_avg_outdoor_temp = convert_to_float(
self.hass.states.get(self.outdoor_sensor).state,
self.name,
self.device_name,
"check_ambient_air_temperature()",
)
if "recorder" in self.hass.config.components:
@@ -170,13 +181,15 @@ async def check_ambient_air_temperature(self):
)
for item in history_list.get(lower_entity_id):
# filter out all None, NaN and "unknown" states
# filter out all None, NaN, "unknown" and "unavailable" states.
# only keep real values
with suppress(ValueError):
if item.state != "unknown":
if item.state not in ("unknown", "unavailable"):
_temp_history.add_measurement(
convert_to_float(
item.state, self.name, "check_ambient_air_temperature()"
item.state,
self.device_name,
"check_ambient_air_temperature()",
),
datetime.fromtimestamp(item.last_updated.timestamp()),
)
@@ -188,7 +201,7 @@ async def check_ambient_air_temperature(self):
avg_temp = self.last_avg_outdoor_temp
_LOGGER.debug(
f"better_thermostat {self.name}: avg outdoor temp: {avg_temp}, threshold is {self.off_temperature}"
f"better_thermostat {self.device_name}: avg outdoor temp: {avg_temp}, threshold is {self.off_temperature}"
)
if avg_temp is not None:
+72 -83
View File
@@ -1,130 +1,119 @@
"""electrolux status integration."""
from pyelectroluxconnect import Session
import asyncio
import logging
from datetime import timedelta
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import Config
from homeassistant.const import (
CONF_LANGUAGE,
CONF_PASSWORD,
CONF_USERNAME,
CONF_COUNTRY_CODE,
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady, ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.helpers.update_coordinator import UpdateFailed
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .pyelectroluxconnect_util import pyelectroluxconnect_util
from .api import Appliance, Appliances, ElectroluxLibraryEntity
from .const import CONF_PASSWORD, CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL, CONF_REGION, DEFAULT_REGION
from .const import CONF_LANGUAGE, DEFAULT_LANGUAGE
from .const import CONF_USERNAME
from .const import DOMAIN
from .const import PLATFORMS
from .const import languages
from .const import (
CONF_RENEW_INTERVAL,
DEFAULT_LANGUAGE,
DEFAULT_COUNTRY_CODE,
DEFAULT_WEBSOCKET_RENEWAL_DELAY,
DOMAIN,
PLATFORMS,
languages,
)
from .coordinator import ElectroluxCoordinator
from .util import get_electrolux_session
_LOGGER: logging.Logger = logging.getLogger(__package__)
# noinspection PyUnusedLocal
async def async_setup(hass: HomeAssistant, config: Config):
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up this integration using YAML is not supported."""
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up this integration using UI."""
if hass.data.get(DOMAIN) is None:
hass.data.setdefault(DOMAIN, {})
if entry.options.get(CONF_SCAN_INTERVAL):
update_interval = timedelta(seconds=entry.options[CONF_SCAN_INTERVAL])
else:
update_interval = timedelta(seconds=DEFAULT_SCAN_INTERVAL)
renew_interval = DEFAULT_WEBSOCKET_RENEWAL_DELAY
if entry.options.get(CONF_RENEW_INTERVAL):
renew_interval = entry.options[CONF_RENEW_INTERVAL]
username = entry.data.get(CONF_USERNAME)
password = entry.data.get(CONF_PASSWORD)
region = entry.data.get(CONF_REGION, DEFAULT_REGION)
language = languages.get(entry.data.get(CONF_LANGUAGE, DEFAULT_LANGUAGE),"eng")
country_code = entry.data.get(CONF_COUNTRY_CODE, DEFAULT_COUNTRY_CODE)
language = languages.get(entry.data.get(CONF_LANGUAGE, DEFAULT_LANGUAGE), "eng")
session = async_get_clientsession(hass)
client = pyelectroluxconnect_util.get_session(username, password, region, language)
client = get_electrolux_session(username, password, country_code, session, language)
coordinator = ElectroluxCoordinator(
hass,
client=client,
renew_interval=renew_interval,
username=username,
)
coordinator = ElectroluxStatusDataUpdateCoordinator(hass, client=client, update_interval=update_interval)
await coordinator.get_stored_token()
if not await coordinator.async_login():
raise ConfigEntryAuthFailed
raise ConfigEntryAuthFailed("Electrolux wrong credentials")
# Bug ?
if coordinator.config_entry is None:
coordinator.config_entry = entry
hass.data[DOMAIN][entry.entry_id] = coordinator
# Initialize entities
_LOGGER.debug("async_setup_entry setup_entities")
await coordinator.setup_entities()
_LOGGER.debug("async_setup_entry listen_websocket")
coordinator.listen_websocket()
# _LOGGER.debug("async_setup_entry launch_websocket_renewal_task")
# await coordinator.launch_websocket_renewal_task()
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, coordinator.api.close)
)
entry.async_on_unload(entry.add_update_listener(update_listener))
_LOGGER.debug("async_setup_entry async_config_entry_first_refresh")
# Fill in the values for first time
await coordinator.async_config_entry_first_refresh()
if not coordinator.last_update_success:
raise ConfigEntryNotReady
hass.data[DOMAIN][entry.entry_id] = coordinator
_LOGGER.debug("async_setup_entry extend PLATFORMS")
coordinator.platforms.extend(PLATFORMS)
# Call async_setup_entry in entity files
_LOGGER.debug("async_setup_entry async_forward_entry_setups")
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.add_update_listener(async_reload_entry)
_LOGGER.debug("async_setup_entry OVER")
return True
class ElectroluxStatusDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to manage fetching data from the API."""
def __init__(self, hass: HomeAssistant, client: Session, update_interval: timedelta) -> None:
"""Initialize."""
self.api = client
self.platforms = []
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval)
async def async_login(self) -> bool:
try:
await self.hass.async_add_executor_job(self.api.login)
except Exception as ex:
_LOGGER.error("Could not log in to ElectroluxStatus, %s", ex)
return False
return True
async def _async_update_data(self):
"""Update data via library."""
await self.async_login()
found_appliances = {}
try:
appliances_json = await self.hass.async_add_executor_job(self.api.getAppliances)
for appliance in appliances_json:
connection_state = await self.hass.async_add_executor_job(self.api.getApplianceConnectionState, appliance)
appliance_state = await self.hass.async_add_executor_job(self.api.getApplianceState, appliance)
appliance_profile = await self.hass.async_add_executor_job(self.api.getApplianceProfile, appliance)
appliance_name = appliances_json[appliance]['alias'] or appliance
appliance_model = appliances_json[appliance]['model'] or appliances_json[appliance]['pnc']
app = Appliance(appliance_name, appliance, appliances_json[appliance]['brand'], appliance_model)
app.setup(ElectroluxLibraryEntity(appliance_name, connection_state, appliance_state, appliance_profile))
found_appliances[appliance_name] = app
return {
"appliances": Appliances(found_appliances)
}
except Exception as exception:
_LOGGER.exception(exception)
raise UpdateFailed() from exception
async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Update listener."""
await hass.config_entries.async_reload(config_entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Handle removal of an entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
unloaded = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(entry, platform)
for platform in PLATFORMS
if platform in coordinator.platforms
]
)
)
if unloaded:
hass.data[DOMAIN].pop(entry.entry_id)
return unloaded
coordinator: ElectroluxCoordinator = hass.data[DOMAIN][entry.entry_id]
await coordinator.close_websocket()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Reload config entry."""
_LOGGER.debug("Electrolux async_reload_entry %s", entry)
await async_unload_entry(hass, entry)
await async_setup_entry(hass, entry)
+649 -278
View File
@@ -1,330 +1,701 @@
"""API for Electrolux Status."""
import copy
import logging
import math
import re
from typing import Any
from pyelectroluxocp.apiModels import ApplianceInfoResponse, ApplienceStatusResponse
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
from homeassistant.components.button import ButtonDeviceClass
from homeassistant.components.number import NumberDeviceClass
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.helpers.entity import EntityCategory
from homeassistant.components.switch import SwitchDeviceClass
from homeassistant.const import Platform, UnitOfTemperature
from .const import BINARY_SENSOR, SENSOR, BUTTON, icon_mapping
from .const import sensors, sensors_binary
from .binary_sensor import ElectroluxBinarySensor
from .button import ElectroluxButton
from .catalog_core import CATALOG_BASE, CATALOG_MODEL
from .const import (
BINARY_SENSOR,
BUTTON,
ATTRIBUTES_BLACKLIST,
NUMBER,
PLATFORMS,
RENAME_RULES,
SELECT,
SENSOR,
STATIC_ATTRIBUTES,
SWITCH, ATTRIBUTES_WHITELIST,
)
from .entity import ElectroluxEntity
from .model import ElectroluxDevice
from .number import ElectroluxNumber
from .select import ElectroluxSelect
from .sensor import ElectroluxSensor
from .switch import ElectroluxSwitch
_LOGGER: logging.Logger = logging.getLogger(__package__)
HEADERS = {"Content-type": "application/json; charset=UTF-8"}
def deep_merge_dicts(dict1, dict2):
"""
Recursively merge two dictionaries.
"""
result = dict1.copy()
for key, value in dict2.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = deep_merge_dicts(result[key], value)
else:
result[key] = value
return result
class ElectroluxLibraryEntity:
def __init__(self, name, status, last_states, appliance_profile):
"""Electrolux Library Entity."""
def __init__(
self,
name,
status: str,
state: ApplienceStatusResponse,
appliance_info: ApplianceInfoResponse,
capabilities: dict[str, Any],
) -> None:
"""Initaliaze the entity."""
self.name = name
self.status: dict = status
self.states = last_states
self.profile = appliance_profile
self.status = status
self.state = state
self.appliance_info = appliance_info
self.capabilities = capabilities
@property
def reported_state(self) -> dict[str, Any]:
"""Return the reported state of the appliance."""
return self.state.get("properties", {}).get("reported")
def get_name(self):
"""Get entity name."""
return self.name
def get_value(self, attr_name, field=None, source=None):
if attr_name in ["TargetMicrowavePower"]:
return self.fix_microwave_power(attr_name, field, source)
if attr_name in ["LinkQualityIndicator"]:
return self.num_to_dbm(attr_name, field, source)
if attr_name in ['StartTime', 'TimeToEnd', 'RunningTime', 'DryingTime', 'ApplianceTotalWorkingTime',
"FCTotalWashingTime"]:
return self.time_to_end_in_minutes(attr_name, field, source)
if attr_name in self.status:
return self.status.get(attr_name)
if attr_name in [self.states[k].get("name") for k in self.states]:
val = self.get_from_states(attr_name, field, source)
if field == "container":
if val["1"]["name"] == "Coefficient" and val["3"]["name"] == "Exponent":
return val["1"]["numberValue"] * (10 ** val["3"]["numberValue"])
def get_value(self, attr_name) -> Any:
"""Return value by attribute."""
if "/" in attr_name:
source, attr = attr_name.split("/")
return self.reported_state.get(source, {}).get(attr, None)
return self.reported_state.get(attr_name, None)
def get_sensor_name(self, attr_name: str) -> str:
"""Get the name of the sensor."""
sensor = attr_name
for truncate_rule in RENAME_RULES:
sensor = re.sub(truncate_rule, "", sensor)
sensor = sensor[0].upper() + sensor[1:]
sensor = sensor.replace("_", " ")
sensor = sensor.replace("/", " ")
group = ""
words = []
for i, char in enumerate(sensor):
if group == "":
group = char
else:
return val
if attr_name in [self.states[st]["container"][cr].get("name") for st in self.states for cr in
self.states[st].get("container", [])]:
return self.get_from_states(attr_name, field, source)
return None
if char == " " and len(group) > 0:
words.append(group)
group = ""
continue
def time_to_end_in_minutes(self, attr_name, field, source):
seconds = self.get_from_states(attr_name, field, source)
if seconds is not None:
if seconds == -1:
return -1
return int(math.ceil((int(seconds) / 60)))
return None
def fix_microwave_power(self, attr_name, field, source):
microwave_power = self.get_from_states(attr_name, field, source)
if microwave_power is not None:
if microwave_power == 65535:
return 0
return microwave_power
return None
def num_to_dbm(self, attr_name, field, source):
number_from_0_to_5 = self.get_from_states(attr_name, field, source)
if number_from_0_to_5 is not None:
if int(number_from_0_to_5) == 0:
return -110
if int(number_from_0_to_5) == 1:
return -80
if int(number_from_0_to_5) == 2:
return -70
if int(number_from_0_to_5) == 3:
return -60
if int(number_from_0_to_5) == 4:
return -55
if int(number_from_0_to_5) == 5:
return -20
return None
def get_from_states(self, attr_name, field, source):
for k in self.states:
if attr_name == self.states[k].get("name") and source == self.states[k].get("source"):
return self._get_states(self.states[k], field) if field else self._get_states(self.states[k])
attr_val = None
for c in self.states[k].get("container", []):
if attr_name == self.states[k]["container"][c].get("name"):
attr_val = self._get_states(self.states[k]["container"][c], field) if field else self._get_states(
self.states[k]["container"][c])
if attr_val is not None:
return attr_val
return None
@staticmethod
def _get_states(states, field=None):
if field:
if field in states.keys():
return states.get(field)
if field == "string":
if "valueTransl" in states.keys():
return states.get("valueTransl").strip(" :.")
if "valTransl" in states.keys():
return states.get("valTransl").strip(" :.")
if "stringValue" in states.keys():
return states.get("stringValue").strip(" :.")
return ""
else:
if "valueTransl" in states.keys():
return states.get("valueTransl").strip(" :.")
if "valTransl" in states.keys():
return states.get("valTransl").strip(" :.")
if "stringValue" in states.keys():
return states.get("stringValue").strip(" :.")
if "numberValue" in states.keys():
return states.get("numberValue")
def get_sensor_name(self, attr_name, source):
for k in self.states:
if attr_name == self.states[k].get("name") and source == self.states[k].get("source"):
if "nameTransl" in self.states[k].keys():
return self.states[k].get("nameTransl").strip(" :.")
if (
(char.isupper() or char.isdigit())
and (sensor[i - 1].isupper() or sensor[i - 1].isdigit())
and (
(i == len(sensor) - 1)
or (sensor[i + 1].isupper() or sensor[i + 1].isdigit())
)
):
group += char
elif (char.isupper() or char.isdigit()) and sensor[i - 1].islower():
if re.match("^[A-Z0-9]+$", group):
words.append(group)
else:
words.append(group.lower())
group = char
else:
return self.states[k].get("name").strip(" :.")
if "container" in self.states[k]:
for c in self.states[k].get("container", []):
if attr_name == self.states[k]["container"][c].get("name"):
if "nameTransl" in self.states[k]["container"][c].keys():
return self.states[k]["container"][c].get("nameTransl").strip(" :.")
else:
return self.states[k]["container"][c].get("name").strip(" :.")
group += char
if len(group) > 0:
if re.match("^[A-Z0-9]+$", group):
words.append(group)
else:
words.append(group.lower())
return " ".join(words).lower().capitalize()
# def get_sensor_name_old(self, attr_name: str, container: str | None = None):
# """Convert sensor format.
# ex: "fCMiscellaneousState/detergentExtradosage" to "Detergent extradosage".
# """
# attr_name = attr_name.rpartition("/")[-1] or attr_name
# attr_name = attr_name[0].upper() + attr_name[1:]
# attr_name = " ".join(re.findall("[A-Z][^A-Z]*", attr_name))
# return attr_name.capitalize()
def get_entity_name(self, attr_name: str) -> str:
"""Extract Entity Name.
ex: Convert format "fCMiscellaneousState/EWX1493A_detergentExtradosage" to "XdetergentExtradosage"
"""
for truncate_rule in RENAME_RULES:
attr_name = re.sub(truncate_rule, "", attr_name)
return attr_name.rpartition("/")[-1] or attr_name
def get_entity_attr(self, attr_name: str) -> str:
"""Extract Entity attr in raw format.
ex: Convert format "fCMiscellaneousState/EWX1493A_detergentExtradosage" to "EWX1493A_detergentExtradosage"
"""
return attr_name.rpartition("/")[-1] or attr_name
def get_category(self, attr_name: str) -> str:
"""Extract category.
ex: "fCMiscellaneousState/detergentExtradosage" to "fCMiscellaneousState".
or "" if none
"""
return attr_name.rpartition("/")[0]
def get_capability(self, attr_name: str) -> dict[str, Any] | None:
"""Retrieve the capability from self.capabilities using the attribute name.
May contain slashes for nested keys.
"""
if self.capabilities.get(attr_name, None):
return self.capabilities.get(attr_name)
keys = attr_name.split("/")
result = self.capabilities
for key in keys:
result = result.get(key)
if result is None:
return None
return result
def get_entity_unit(self, attr_name: str):
"""Get entity unit type."""
capability_def: dict[str, Any] | None = self.get_capability(attr_name)
if not capability_def:
return None
# Type : string, int, number, boolean (other values ignored)
type_units = capability_def.get("type", None)
if not type_units:
return None
if type_units == "temperature":
return UnitOfTemperature.CELSIUS
return None
def value_exists(self, attr_name, source):
_container_attr = []
for k in self.states:
for c in self.states[k].get("container", []):
_container_attr.append(self.states[k]["container"][c].get("name"))
return (attr_name in self.status) or \
(attr_name in [self.states[k].get("name") for k in self.states if
self.states[k].get("source") == source]) or \
(attr_name in [self.profile[k].get("name") for k in self.profile if
self.profile[k].get("source") == source]) or \
(attr_name in _container_attr)
def get_entity_device_class(self, attr_name: str):
"""Get entity device class."""
capability_def: dict[str, Any] | None = self.get_capability(attr_name)
if not capability_def:
return None
# Type : string, int, number, boolean (other values ignored)
type_class = capability_def.get("type", None)
if not type_class:
return None
if type_class == "temperature":
if capability_def.get("access", None) == "readwrite":
return NumberDeviceClass.TEMPERATURE
return SensorDeviceClass.TEMPERATURE
return None
def sources_list(self):
return list(
{self.states[k].get("source") for k in self.states if self.states[k].get("source") not in ["NIU", "APL"]}
def get_entity_type(self, attr_name: str) -> Platform | None:
"""Get entity type."""
capability_def: dict[str, Any] | None = self.get_capability(attr_name)
if not capability_def:
return None
# Type : string, int, number, boolean (other values ignored)
type_object = capability_def.get("type", None)
if not type_object:
return None
# Access : read, readwrite (other values ignored)
access = capability_def.get("access", None)
if not access:
return None
# Exception (Electrolux bug)
if (
type_object == "boolean"
and access == "readwrite"
and capability_def.get("values", None) is not None
):
return SWITCH
# List of values ? if values is defined and has at least 1 entry
values: dict[str, Any] | None = capability_def.get("values", None)
if (
values
and access == "readwrite"
and isinstance(values, dict)
and len(values) > 0
):
if (
type_object not in ["number", "temperature"]
or capability_def.get("min", None) is None
):
return SELECT
match type_object:
case "boolean":
if access == "read":
return BINARY_SENSOR
if access == "readwrite":
return SWITCH
case "temperature":
if access == "read":
return SENSOR
if access == "readwrite":
return NUMBER
case "alert":
return SENSOR
case _:
if (
self.get_entity_name(attr_name) == "executeCommand"
and access == "read"
): # FIX for https://github.com/albaintor/homeassistant_electrolux_status/issues/74
return BUTTON
if access == "write":
return BUTTON
if access == "constant":
return SENSOR
if access == "read" and type_object in [
"number",
"int",
"boolean",
"string",
]:
return SENSOR
if type_object in ("int", "number"):
return NUMBER
_LOGGER.debug(
"Electrolux unable to determine type for %s. Type: %s Access: %s",
attr_name,
type_object,
access,
)
def commands_list(self, source):
commands = list(self.profile[k].get("steps") for k in self.profile if
self.profile[k].get("source") == source and self.profile[k].get("name") == "ExecuteCommand")
if len(commands) > 0:
return commands[0]
else:
return {}
def get_command_name(self, command_desc):
if "transl" in command_desc:
return command_desc["transl"]
elif "key" in command_desc:
return command_desc["key"]
return None
def get_suffix(self, attr_name, source):
res = list({self.states[k].get("source") for k in self.states if self.states[k].get("name") == attr_name})
if len(res) == 1:
return ""
else:
if source in res:
return f" ({source})"
return ""
def sources_list(self) -> list[str] | None:
"""List the capability types."""
if self.capabilities is None:
_LOGGER.warning("Electrolux capabilities list is empty")
return None
# dont load these entities by as they are not useful
# we do load some of these directly via STATIC_ATTRIBUTES as
# one or another are useful, but not all child values are
class ApplianceEntity:
entity_type = None
def keep_source(source: str) -> bool:
for ignored_pattern in ATTRIBUTES_BLACKLIST:
if re.match(ignored_pattern, source):
for whitelist_pattern in ATTRIBUTES_WHITELIST:
if re.match(whitelist_pattern, source):
return True
_LOGGER.debug("Exclude source %s from list", source)
return False
return True
def __init__(self, name, attr, device_class=None, entity_category=None, field=None, source=None) -> None:
self.attr = attr
self.name = name
self.device_class = device_class
self.entity_category = entity_category
self.field = field
self.source = source
self.val_to_send = None
self.icon = None
self._state = None
sources = [
key
for key in list(self.capabilities.keys())
if keep_source(key)
]
def setup(self, data: ElectroluxLibraryEntity):
self._state = data.get_value(self.attr, self.field, self.source)
return self
for key, value in self.capabilities.items():
if not keep_source(key):
continue
def clear_state(self):
self._state = None
if isinstance(value, dict):
for sub_key, sub_value in value.items():
if (
isinstance(sub_value, dict)
and "access" in sub_value
and "type" in sub_value
):
sources.append(f"{key}/{sub_key}")
elif "access" in value and "type" in value:
sources.append(key)
return sources
class ApplianceSensor(ApplianceEntity):
entity_type = SENSOR
def __init__(self, name, attr, unit=None, device_class=None, entity_category=None, field=None, source=None) -> None:
super().__init__(name, attr, device_class, entity_category, field, source)
self.unit = unit
@property
def state(self):
return self._state
class ApplianceBinary(ApplianceEntity):
entity_type = BINARY_SENSOR
def __init__(self, name, attr, device_class=None, entity_category=None, field=None, invert=False,
source=None) -> None:
super().__init__(name, attr, device_class, entity_category, field, source)
self.invert = invert
@property
def state(self):
state = self._state in [1, 'enabled', True, 'Connected', 'connect']
return not state if self.invert else state
class ApplianceButton(ApplianceEntity):
entity_type = BUTTON
def __init__(self, name, attr, unit=None, device_class=None, entity_category=None, source=None, val_to_send=None,
icon=None) -> None:
super().__init__(name, attr, device_class, entity_category, None, source)
self.val_to_send = val_to_send
self.icon = icon
def setup(self, data: ElectroluxLibraryEntity):
return self
# def sources_list_old(self):
# _LOGGER.warning(self.capabilities)
# return [
# key
# for key in list(self.capabilities.keys())
# if not key.startswith("applianceCareAndMaintenance")
# ]
class Appliance:
"""Define the Appliance Class."""
brand: str
device: str
entities: []
entities: list[ElectroluxEntity]
coordinator: Any
def __init__(self, name, pnc_id, brand, model) -> None:
def __init__(
self,
coordinator: Any,
name: str,
pnc_id: str,
brand: str,
model: str,
state: ApplienceStatusResponse,
) -> None:
"""Initiate the appliance."""
self.own_capabilties = False
self.data = None
self.coordinator = coordinator
self.model = model
self.pnc_id = pnc_id
self.name = name
self.brand = brand
self.state: ApplienceStatusResponse = state
def get_entity(self, entity_type, entity_attr, entity_source, val_to_send):
return next(
entity
for entity in self.entities
if
entity.attr == entity_attr and entity.entity_type == entity_type and entity.source == entity_source and entity.val_to_send == val_to_send
@property
def reported_state(self) -> dict[str, Any]:
"""Return the reported state of the appliance."""
return self.state.get("properties", {}).get("reported", {})
@property
def appliance_type(self) -> dict[str, Any]:
"""Return the reported type of the appliance.
CR: Refridgerator
WM: Washing Machine
"""
return self.reported_state.get("applianceInfo", {}).get(
"applianceType"
) or self.state.get("applianceData", {}).get("modelName")
@property
def catalog(self) -> dict[str, ElectroluxDevice]:
"""Return the defined catalog for the appliance."""
# TODO: Use appliance_type as opposed to model?
if self.model in CATALOG_MODEL:
_LOGGER.debug("Extending catalog for %s", self.model)
# Make a deep copy of the base catalog to preserve it
new_catalog = copy.deepcopy(CATALOG_BASE)
# Get the specific model's extended catalog
model_catalog = CATALOG_MODEL[self.model]
# Update the existing catalog with the extended information for this model
for key, device in model_catalog.items():
new_catalog[key] = device
return new_catalog
return CATALOG_BASE
def update_missing_entities(self) -> None:
"""Add missing entities when no capabilities returned by the API.
This is done dynamically but only when the reported state contains the attributes.
"""
if not self.own_capabilties or not self.reported_state:
return
for key, catalog_item in self.catalog.items():
category = self.data.get_category(key)
if (
category
and self.reported_state.get(category, None)
and self.reported_state.get(category, None).get(key)
) or (not category and self.reported_state.get(key, None)):
found: bool = False
for entity in self.entities:
if entity.entity_attr == key and entity.entity_source == category:
found = True
keys = key.split("/")
capabilities = self.data.capabilities
for key in keys[:-1]:
capabilities = capabilities.setdefault(key, {})
capabilities[keys[-1]] = catalog_item.capability_info
break
if not found:
_LOGGER.debug(
"Electrolux discovered new entity from extracted data. Key: %s",
key,
)
if entity := self.get_entity(key):
self.entities.extend(entity)
def get_state(self, attr_name: str) -> dict[str, Any] | None:
"""Retrieve the start from self.reported_state using the attribute name.
May contain slashes for nested keys.
"""
keys = attr_name.split("/")
result = self.reported_state
for key in keys:
result = result.get(key)
if result is None:
return None
return result
def get_entity(self, capability: str) -> list[ElectroluxEntity] | None:
"""Return the entity."""
entity_type = self.data.get_entity_type(capability)
entity_name = self.data.get_entity_name(capability)
entity_attr = self.data.get_entity_attr(capability)
category = self.data.get_category(capability)
capability_info = self.data.get_capability(capability)
device_class = self.data.get_entity_device_class(capability)
entity_category = None
entity_icon = None
unit = self.data.get_entity_unit(capability)
display_name = self.data.get_sensor_name(capability)
# get the item definition from the catalog
catalog_item = self.catalog.get(capability, None)
if catalog_item:
if capability_info is None:
capability_info = catalog_item.capability_info
elif (
"values" not in capability_info
and "values" in catalog_item.capability_info
):
capability_info["values"] = catalog_item.capability_info["values"]
device_class = catalog_item.device_class
unit = catalog_item.unit
entity_category = catalog_item.entity_category
entity_icon = catalog_item.entity_icon
# override the api determined type by the catalog entity_type
if isinstance(device_class, BinarySensorDeviceClass):
entity_type = BINARY_SENSOR
if isinstance(device_class, ButtonDeviceClass):
entity_type = BUTTON
if isinstance(device_class, NumberDeviceClass):
entity_type = NUMBER
if isinstance(device_class, SensorDeviceClass):
entity_type = SENSOR
if isinstance(device_class, SwitchDeviceClass):
entity_type = SWITCH
# override the api determined type by the catalog entity_platform
if catalog_item and isinstance(catalog_item.entity_platform, Platform):
entity_type = catalog_item.entity_platform
_LOGGER.debug(
"Electrolux get_entity. entity_type: %s entity_name: %s entity_attr: %s entity_source: %s capability: %s device_class: %s unit: %s, catalog: %s",
entity_type,
entity_name,
entity_attr,
category,
capability_info,
device_class,
unit,
catalog_item,
)
def setup(self, data: ElectroluxLibraryEntity):
entities = [
ApplianceBinary(
name=data.get_name(),
attr='status',
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
source='APL',
),
ApplianceSensor(
name=f"{data.get_name()} SSID",
attr='Ssid',
entity_category=EntityCategory.DIAGNOSTIC,
source='NIU',
),
ApplianceSensor(
name=f"{data.get_name()} {data.get_sensor_name('LinkQualityIndicator', 'NIU')}",
attr='LinkQualityIndicator',
field='numberValue',
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
unit="dBm",
entity_category=EntityCategory.DIAGNOSTIC,
source='NIU',
),
]
sources = data.sources_list()
for src in sources:
for sensor_type, sensors_list in sensors.items():
for sensorName, params in sensors_list.items():
entities.append(
ApplianceSensor(
name=f"{data.get_name()} {data.get_sensor_name(sensorName, src)}{data.get_suffix(sensorName, src)}",
attr=sensorName,
field=params[0],
device_class=params[1],
entity_category=sensor_type,
unit=params[2],
source=src,
)
)
for sensor_type, sensors_list in sensors_binary.items():
for sensorName, params in sensors_list.items():
entities.append(
ApplianceBinary(
name=f"{data.get_name()} {data.get_sensor_name(sensorName, src)}{data.get_suffix(sensorName, src)}",
attr=sensorName,
field=params[0],
device_class=params[1],
entity_category=sensor_type,
invert=params[2],
source=src,
)
)
for key, command in data.commands_list(src).items():
entities.append(
ApplianceButton(
name=f"{data.get_name()} {data.get_command_name(command)}{data.get_suffix('ExecuteCommand', src)}",
attr='ExecuteCommand',
val_to_send=key,
source=src,
icon=icon_mapping.get(key, "mdi:gesture-tap-button"),
)
)
def electrolux_entity_factory(
name: str,
entity_type: Platform | None,
entity_name: str,
entity_attr: str,
entity_source: str,
capability: str,
unit: str,
entity_category: str,
device_class: str,
icon: str,
catalog_entry: ElectroluxDevice | None,
commands: Any | None = None,
):
entity_classes = {
BINARY_SENSOR: ElectroluxBinarySensor,
BUTTON: ElectroluxButton,
NUMBER: ElectroluxNumber,
SELECT: ElectroluxSelect,
SENSOR: ElectroluxSensor,
SWITCH: ElectroluxSwitch,
}
self.entities = [
entity_class = entity_classes.get(entity_type)
if entity_class is None:
_LOGGER.debug("Unknown entity type %s for %s", entity_type, name)
raise ValueError(f"Unknown entity type: {entity_type}")
entity_params = {
"coordinator": self.coordinator,
"config_entry": self.coordinator.config_entry,
"pnc_id": self.pnc_id,
"name": name,
"entity_type": entity_type,
"entity_name": entity_name,
"entity_attr": entity_attr,
"entity_source": entity_source,
"capability": capability,
"unit": unit,
"entity_category": entity_category,
"device_class": device_class,
"icon": icon,
"catalog_entry": catalog_entry,
}
if commands is None:
return [entity_class(**entity_params)]
entities: list[
ElectroluxBinarySensor
| ElectroluxNumber
| ElectroluxSensor
| ElectroluxSwitch
| ElectroluxButton
| ElectroluxSelect
] = []
# Replace entity name and icons for multi-entities attribute (one value = one entity)
for command in commands:
entity = {**entity_params, "val_to_send": command}
if catalog_item:
if catalog_item.entity_value_named:
entity["name"] = command
if (
catalog_item.entity_icons_value_map
and catalog_item.entity_icons_value_map.get(command, None)
):
entity["icon"] = catalog_item.entity_icons_value_map.get(
command
)
# Instanciate the new entity and append it
entities.append(entity_class(**entity))
return entities
if entity_type in PLATFORMS:
commands = (
capability_info.get("values", {}) if entity_type == BUTTON else None
)
return electrolux_entity_factory(
name=display_name,
entity_type=entity_type,
entity_name=entity_name,
entity_attr=entity_attr,
entity_source=category,
capability=capability_info,
unit=unit,
entity_category=entity_category,
device_class=device_class,
icon=entity_icon,
catalog_entry=catalog_item,
commands=commands,
)
return []
def setup(self, data: ElectroluxLibraryEntity):
"""Configure the entity."""
self.data: ElectroluxLibraryEntity = data
self.entities: list[ElectroluxEntity] = []
entities: list[ElectroluxEntity] = []
# Extraction of the appliance capabilities & mapping to the known entities of the component
# [ "applianceState", "autoDosing",..., "userSelections/analogTemperature",...]
capabilities_names = self.data.sources_list()
if capabilities_names is None and self.state:
# No capabilities returned (unstable API)
# We could rebuild them from catalog but this creates entities that are
# not required by each device type (fridge, dryer, vacumn etc are all different)
_LOGGER.warning("Electrolux API returned no capability definition")
# Add static attribute
# these are attributes that are not in the capability entry
# but are returned by the api independantly
for static_attribute in STATIC_ATTRIBUTES:
_LOGGER.debug("Electrolux static_attribute %s", static_attribute)
# attr not found in state, next attr
if self.get_state(static_attribute) is None:
continue
if catalog_item := self.catalog.get(static_attribute, None):
if (entity := self.get_entity(static_attribute)) is None:
# catalog definition and automatic checks fail to determine type
_LOGGER.debug(
"Electrolux static_attribute undefined %s", static_attribute
)
continue
# add to the capability dict
keys = static_attribute.split("/")
capabilities = self.data.capabilities
for key in keys[:-1]:
capabilities = capabilities.setdefault(key, {})
capabilities[keys[-1]] = catalog_item.capability_info
_LOGGER.debug("Electrolux adding static_attribute %s", static_attribute)
entities.extend(entity)
# For each capability src
if capabilities_names:
for capability in capabilities_names:
if entity := self.get_entity(capability):
entities.extend(entity)
else:
_LOGGER.debug("Could not create entity for capability %s", capability)
# Setup each found entity
self.entities = entities
for entity in entities:
entity.setup(data)
for entity in entities if data.value_exists(entity.attr, entity.source)
]
def update_reported_data(self, reported_data: dict[str, Any]):
"""Update the reported data."""
_LOGGER.debug("Electrolux update reported data %s", reported_data)
try:
self.reported_state.update(deep_merge_dicts(self.reported_state, reported_data))
_LOGGER.debug("Electrolux updated reported data %s", self.state)
self.update_missing_entities()
for entity in self.entities:
entity.update(self.state)
except Exception as ex: # noqa: BLE001
_LOGGER.debug(
"Electrolux status could not update reported data with %s. %s",
reported_data,
ex,
)
def update(self, appliance_status: ApplienceStatusResponse):
"""Update appliance status."""
self.state = appliance_status
self.update_missing_entities()
for entity in self.entities:
entity.update(self.state)
class Appliances:
def __init__(self, found_appliances) -> None:
self.found_appliances = found_appliances
"""Appliance class definition."""
def get_appliance(self, pnc_id):
return self.found_appliances.get(pnc_id, None)
def __init__(self, appliances: dict[str, Appliance]) -> None:
"""Initialize the class."""
self.appliances = appliances
def get_appliance(self, pnc_id) -> Appliance:
"""Return the appliance."""
return self.appliances.get(pnc_id, None)
def get_appliances(self) -> dict[str, Appliance]:
"""Return all appliances."""
return self.appliances
def get_appliance_ids(self) -> list[str]:
"""Return all appliance ids."""
return list(self.appliances)
@@ -1,30 +1,66 @@
"""Binary sensor platform for Electrolux Status."""
import logging
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .entity import ElectroluxStatusEntity
from .const import BINARY_SENSOR
from .const import DOMAIN
from .const import BINARY_SENSOR, DOMAIN
from .entity import ElectroluxEntity
from .util import string_to_boolean
_LOGGER: logging.Logger = logging.getLogger(__package__)
async def async_setup_entry(hass, entry, async_add_devices):
"""Setup binary sensor platform."""
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Configure binary sensor platform."""
coordinator = hass.data[DOMAIN][entry.entry_id]
appliances = coordinator.data.get('appliances', None)
if appliances is not None:
for appliance_id, appliance in appliances.found_appliances.items():
async_add_devices(
[
ElectroluxBinarySensor(coordinator, entry, appliance_id, entity.entity_type, entity.attr, entity.source)
for entity in appliance.entities if entity.entity_type == BINARY_SENSOR
]
if appliances := coordinator.data.get("appliances", None):
for appliance_id, appliance in appliances.appliances.items():
entities = [
entity
for entity in appliance.entities
if entity.entity_type == BINARY_SENSOR
]
_LOGGER.debug(
"Electrolux add %d BINARY_SENSOR entities to registry for appliance %s",
len(entities),
appliance_id,
)
async_add_entities(entities)
class ElectroluxBinarySensor(ElectroluxStatusEntity, BinarySensorEntity):
class ElectroluxBinarySensor(ElectroluxEntity, BinarySensorEntity):
"""Electrolux Status binary_sensor class."""
@property
def is_on(self):
def entity_domain(self):
"""Enitity domain for the entry. Used for consistent entity_id."""
return BINARY_SENSOR
@property
def invert(self) -> bool:
"""Determine if the value returned for the entity needs to be reversed."""
if self.catalog_entry:
return self.catalog_entry.state_invert
return False
@property
def is_on(self) -> bool:
"""Return true if the binary_sensor is on."""
return self.get_entity.state
value = self.extract_value()
if isinstance(value, str):
value = string_to_boolean(value, True)
if value is None:
if self.catalog_entry and self.catalog_entry.state_mapping:
mapping = self.catalog_entry.state_mapping
value = self.get_state_attr(mapping)
if value is not None:
self._cached_value = value
return not self._cached_value if self.invert else self._cached_value
+128 -18
View File
@@ -1,30 +1,140 @@
"""Button platform for Electrolux Status."""
import logging
from typing import Any
from pyelectroluxocp.oneAppApi import OneAppApi
from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .entity import ElectroluxButtonEntity
from .const import BUTTON
from .const import DOMAIN
from .const import BUTTON, DOMAIN, icon_mapping
from .entity import ElectroluxEntity
from .model import ElectroluxDevice
_LOGGER: logging.Logger = logging.getLogger(__package__)
async def async_setup_entry(hass, entry, async_add_devices):
"""Setup button platform."""
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Configure button platform."""
coordinator = hass.data[DOMAIN][entry.entry_id]
appliances = coordinator.data.get('appliances', None)
if appliances is not None:
for appliance_id, appliance in appliances.found_appliances.items():
async_add_devices(
[
ElectroluxButton(coordinator, entry, appliance_id, entity.entity_type, entity.attr, entity.source, entity.val_to_send, entity.icon)
for entity in appliance.entities if entity.entity_type == BUTTON
]
if appliances := coordinator.data.get("appliances", None):
for appliance_id, appliance in appliances.appliances.items():
entities = [
entity for entity in appliance.entities if entity.entity_type == BUTTON
]
_LOGGER.debug(
"Electrolux add %d BUTTON entities to registry for appliance %s",
len(entities),
appliance_id,
)
async_add_entities(entities)
class ElectroluxButton(ElectroluxButtonEntity, ButtonEntity):
class ElectroluxButton(ElectroluxEntity, ButtonEntity):
"""Electrolux Status button class."""
async def async_press(self) -> None:
if self.entity_attr == "ExecuteCommand":
await self.hass.async_add_executor_job(self.coordinator.api.setHacl, self.get_appliance.pnc_id, "0x0403", self.val_to_send, self.entity_source)
def __init__(
self,
coordinator: Any,
name: str,
config_entry,
pnc_id: str,
entity_type: str,
entity_name,
entity_attr,
entity_source,
capability: dict[str, Any],
unit: str,
device_class: str,
entity_category: EntityCategory,
icon: str,
catalog_entry: ElectroluxDevice | None,
val_to_send: str,
) -> None:
"""Initialize the Button Entity."""
super().__init__(
coordinator=coordinator,
capability=capability,
name=name,
config_entry=config_entry,
pnc_id=pnc_id,
entity_type=entity_type,
entity_name=entity_name,
entity_attr=entity_attr,
entity_source=entity_source,
unit=None,
device_class=device_class,
entity_category=entity_category,
icon=icon,
catalog_entry=catalog_entry,
)
self.val_to_send = val_to_send
@property
def entity_domain(self):
"""Enitity domain for the entry. Used for consistent entity_id."""
return BUTTON
@property
def unique_id(self) -> str:
"""Return a unique ID to use for this entity."""
return f"{self.config_entry.entry_id}-{self.val_to_send}-{self.entity_attr}-{self.entity_source}-{self.pnc_id}"
@property
def name(self) -> str:
"""Return the name of the sensor."""
name = self._name
if self.catalog_entry and self.catalog_entry.friendly_name:
name = (
f"{self.get_appliance.name} {self.catalog_entry.friendly_name.lower()}"
)
# Get the last word from the 'name' variable
# and compare to the command we are sending duplicate names
# "air filter state reset reset" for instance
last_word = name.split()[-1]
if last_word.lower() == str(self.val_to_send).lower():
return name
return f"{name} {self.val_to_send}"
@property
def icon(self) -> str | None:
"""Return the icon of the entity."""
return self._icon or icon_mapping.get(
self.val_to_send, "mdi:gesture-tap-button"
)
async def send_command(self) -> bool:
"""Send a command to the device."""
client: OneAppApi = self.api
value = self.val_to_send
if self.entity_source:
if self.entity_source == "userSelections":
command = {
self.entity_source: {
"programUID": self.appliance_status["properties"]['reported']["userSelections"]['programUID'],
self.entity_attr: value
},
}
else:
command = {self.entity_source: {self.entity_attr: value}}
else:
command = {self.entity_attr: value}
_LOGGER.debug("Electrolux send command %s", command)
result = await client.execute_appliance_command(self.pnc_id, command)
_LOGGER.debug("Electrolux send command result %s", result)
return True
async def async_press(self) -> None:
"""Execute a button press."""
await self.send_command()
# await self.hass.async_add_executor_job(self.send_command)
# if self.entity_attr == "ExecuteCommand":
# await self.hass.async_add_executor_job(self.coordinator.api.setHacl, self.get_appliance.pnc_id, "0x0403", self.val_to_send, self.entity_source)
@@ -1,169 +1,237 @@
"""Adds config flow for Electrolux Status."""
from collections.abc import Mapping
import logging
from typing import Any
import homeassistant.helpers.config_validation as cv
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.selector import selector
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import callback
from typing import Mapping, Any
from .pyelectroluxconnect_util import pyelectroluxconnect_util
from .const import CONF_PASSWORD, CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL, CONF_REGION
from .const import CONF_LANGUAGE, DEFAULT_LANGUAGE
from .const import CONF_USERNAME
from .const import DOMAIN
from .const import languages
from homeassistant.config_entries import (
CONN_CLASS_CLOUD_PUSH,
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, CONF_COUNTRY_CODE
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
selector,
)
from .const import (
CONF_LANGUAGE,
CONF_NOTIFICATION_DEFAULT,
CONF_NOTIFICATION_DIAG,
CONF_NOTIFICATION_WARNING,
DEFAULT_LANGUAGE,
DEFAULT_COUNTRY_CODE,
DOMAIN,
languages,
)
from .util import get_electrolux_session
_LOGGER = logging.getLogger(__name__)
class ElectroluxStatusFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
class ElectroluxStatusFlowHandler(ConfigFlow, domain=DOMAIN):
"""Config flow for Electrolux Status."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
CONNECTION_CLASS = CONN_CLASS_CLOUD_PUSH
def __init__(self):
def __init__(self) -> None:
"""Initialize."""
self._errors = {}
async def async_step_user(self, user_input=None):
async def async_step_user(self, user_input=None) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
self._errors = {}
# Uncomment the next 2 lines if only a single instance of the integration is allowed:
# if self._async_current_entries():
# return self.async_abort(reason="single_instance_allowed")
if user_input is not None:
# check if the specified account is configured already
# to prevent them from being added twice
for entry in self._async_current_entries():
if user_input[CONF_USERNAME] == entry.data.get("username", None):
return self.async_abort(reason="already_configured_account")
valid = await self._test_credentials(
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
user_input[CONF_REGION],
user_input[CONF_USERNAME], user_input[CONF_PASSWORD], user_input[CONF_COUNTRY_CODE]
)
if valid:
return self.async_create_entry(
title=user_input[CONF_USERNAME], data=user_input
)
else:
self._errors["base"] = "auth"
self._errors["base"] = "invalid_auth"
return await self._show_config_form(user_input)
return await self._show_config_form(user_input)
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle configuration by re-auth."""
return await self.async_step_reauth_validate(entry_data)
async def async_step_reauth_validate(self, user_input=None):
async def async_step_reauth_validate(self, user_input=None) -> ConfigFlowResult:
"""Handle reauth and validation."""
self._errors = {}
if user_input is not None:
valid = await self._test_credentials(
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
user_input[CONF_REGION],
user_input[CONF_USERNAME], user_input[CONF_PASSWORD], user_input[CONF_COUNTRY_CODE]
)
if valid:
return self.async_create_entry(
title=user_input[CONF_USERNAME], data=user_input
)
else:
self._errors["base"] = "auth"
self._errors["base"] = "invalid_auth"
return await self._show_config_form(user_input)
return await self._show_config_form(user_input)
@staticmethod
@callback
def async_get_options_flow(config_entry):
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
"""Present the configuration options dialog."""
return ElectroluxStatusOptionsFlowHandler(config_entry)
async def _show_config_form(self, user_input): # pylint: disable=unused-argument
"""Show the configuration form to edit location data."""
data_schema = {
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
vol.Optional(CONF_REGION, default="EMEA"): selector({
"select": {
"options": ["APAC", "EMEA", "LATAM", "NA", "Frigidaire"],
"mode": "dropdown",
}
}),
vol.Optional(CONF_LANGUAGE, default = DEFAULT_LANGUAGE): selector({
"select": {
"options": list(languages.keys()),
"mode": "dropdown"}
}),
vol.Required(CONF_USERNAME): TextSelector(
TextSelectorConfig(type=TextSelectorType.EMAIL, autocomplete="username")
),
vol.Required(CONF_PASSWORD): TextSelector(
TextSelectorConfig(
type=TextSelectorType.PASSWORD, autocomplete="current-password"
)
),
vol.Required(CONF_COUNTRY_CODE, default=DEFAULT_COUNTRY_CODE): TextSelector(
TextSelectorConfig(
type=TextSelectorType.TEXT, autocomplete="country-code"
)
),
}
if self.show_advanced_options:
data_schema = {
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
vol.Optional(CONF_REGION, default="EMEA"): selector({
"select": {
"options": ["APAC", "EMEA", "LATAM", "NA", "Frigidaire"],
"mode": "dropdown"}
}),
vol.Optional(CONF_LANGUAGE, default = DEFAULT_LANGUAGE): selector({
"select": {
"options": list(languages.keys()),
"mode": "dropdown"}
}),
}
data_schema.update(
{
vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): selector(
{
"select": {
"options": list(languages.keys()),
"mode": "dropdown",
}
}
),
vol.Optional(CONF_NOTIFICATION_DEFAULT, default=True): cv.boolean,
vol.Optional(CONF_NOTIFICATION_WARNING, default=False): cv.boolean,
vol.Optional(CONF_NOTIFICATION_DIAG, default=False): cv.boolean,
}
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(data_schema),
errors=self._errors,
)
async def _test_credentials(self, username, password, region):
async def _test_credentials(self, username, password, country_code):
"""Return true if credentials is valid."""
try:
client = pyelectroluxconnect_util.get_session(username, password, region)
await self.hass.async_add_executor_job(client.login)
return True
except Exception as inst: # pylint: disable=broad-except
_LOGGER.exception(inst)
return False
client = get_electrolux_session(
username, password, country_code, async_get_clientsession(self.hass)
)
await client.get_appliances_list()
except Exception as inst: # pylint: disable=broad-except # noqa: BLE001
_LOGGER.error("Authentication to electrolux failed: %s", inst)
return False
return True
class ElectroluxStatusOptionsFlowHandler(config_entries.OptionsFlow):
class ElectroluxStatusOptionsFlowHandler(OptionsFlow):
"""Config flow options handler for Electrolux Status."""
def __init__(self, config_entry):
def __init__(self, config_entry) -> None:
"""Initialize HACS options flow."""
self.config_entry = config_entry
self.options = dict(config_entry.options)
async def async_step_init(self, user_input=None): # pylint: disable=unused-argument
async def async_step_init(self, user_input=None) -> ConfigFlowResult:
"""Manage the options."""
return await self.async_step_user()
async def async_step_user(self, user_input=None):
async def async_step_user(self, user_input=None) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
if user_input is not None:
self.options.update(user_input)
return await self._update_options()
selected_language = self.config_entry.data.get(CONF_LANGUAGE, DEFAULT_LANGUAGE)
current_password = self.config_entry.data.get(CONF_PASSWORD, None)
current_country_code = self.config_entry.data.get(CONF_COUNTRY_CODE, None)
notify_alert = self.config_entry.data.get(CONF_NOTIFICATION_DEFAULT, True)
notify_warning = self.config_entry.data.get(CONF_NOTIFICATION_WARNING, False)
notify_diagnostic = self.config_entry.data.get(CONF_NOTIFICATION_DIAG, False)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_PASSWORD, default=current_password): TextSelector(
TextSelectorConfig(
type=TextSelectorType.PASSWORD,
autocomplete="current-password",
)
),
vol.Required(CONF_COUNTRY_CODE, default=current_country_code): TextSelector(
TextSelectorConfig(
type=TextSelectorType.TEXT, autocomplete="country-code"
)
),
vol.Required(CONF_LANGUAGE, default=selected_language): selector(
{
"select": {
"options": list(languages.keys()),
"mode": "dropdown",
}
}
),
vol.Optional(
CONF_SCAN_INTERVAL,
default=self.config_entry.options.get(
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
),
): cv.positive_int,
CONF_NOTIFICATION_DEFAULT, default=notify_alert
): cv.boolean,
vol.Optional(
CONF_NOTIFICATION_WARNING, default=notify_warning
): cv.boolean,
vol.Optional(
CONF_NOTIFICATION_DIAG, default=notify_diagnostic
): cv.boolean,
# vol.Optional(
# CONF_RENEW_INTERVAL,
# default=self.config_entry.options.get(
# CONF_RENEW_INTERVAL, DEFAULT_WEBSOCKET_RENEWAL_DELAY
# ),
# ): cv.positive_int,
}
),
)
async def _update_options(self):
"""Update config entry options."""
_LOGGER.debug("Electrolux updating configuration")
data = {
**self.config_entry.data,
CONF_PASSWORD: self.options[CONF_PASSWORD],
CONF_COUNTRY_CODE: self.options[CONF_COUNTRY_CODE],
CONF_LANGUAGE: self.options[CONF_LANGUAGE],
CONF_NOTIFICATION_DEFAULT: self.options[CONF_NOTIFICATION_DEFAULT],
CONF_NOTIFICATION_WARNING: self.options[CONF_NOTIFICATION_WARNING],
CONF_NOTIFICATION_DIAG: self.options[CONF_NOTIFICATION_DIAG],
}
self.hass.config_entries.async_update_entry(self.config_entry, data=data)
return self.async_create_entry(
title=self.config_entry.data.get(CONF_USERNAME), data=self.options
title=self.config_entry.data.get(CONF_USERNAME),
data=data,
)
+58 -108
View File
@@ -1,129 +1,58 @@
"""The electrolux Status constants."""
from homeassistant.const import UnitOfTime
from homeassistant.const import UnitOfTemperature
from homeassistant.const import UnitOfPower
from homeassistant.const import PERCENTAGE
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
from homeassistant.helpers.entity import EntityCategory
import re
from homeassistant.const import Platform
# Base component constants
NAME = "Elettrolux status"
NAME = "Electrolux status"
DOMAIN = "electrolux_status"
DOMAIN_DATA = f"{DOMAIN}_data"
# Icons
ICON = "mdi:format-quote-close"
# Device classes
BINARY_SENSOR_DEVICE_CLASS = "connectivity"
COMPONENTS_DIRECTORY = "custom_components"
LOOKUP_DIRECTORY = "appliance_definitions"
LOOKUP_DIRECTORY_PATH = f"{COMPONENTS_DIRECTORY}/{DOMAIN}/{LOOKUP_DIRECTORY}/"
# Platforms
BINARY_SENSOR = "binary_sensor"
SENSOR = "sensor"
BUTTON = "button"
PLATFORMS = [BINARY_SENSOR, SENSOR, BUTTON]
BINARY_SENSOR = Platform.BINARY_SENSOR
BUTTON = Platform.BUTTON
NUMBER = Platform.NUMBER
SELECT = Platform.SELECT
SENSOR = Platform.SENSOR
SWITCH = Platform.SWITCH
PLATFORMS = [BINARY_SENSOR, BUTTON, NUMBER, SELECT, SENSOR, SWITCH]
# Configuration and options
CONF_ENABLED = "enabled"
CONF_USERNAME = "username"
CONF_PASSWORD = "password"
CONF_REGION = "region"
CONF_LANGUAGE = "language"
CONF_SCAN_INTERVAL = "scan_interval"
CONF_RENEW_INTERVAL = "renew_interval"
CONF_NOTIFICATION_DEFAULT = "notifications"
CONF_NOTIFICATION_DIAG = "notifications_diagnostic"
CONF_NOTIFICATION_WARNING = "notifications_warning"
# Defaults
DEFAULT_NAME = DOMAIN
DEFAULT_SCAN_INTERVAL = 30
DEFAULT_REGION = "emea"
DEFAULT_LANGUAGE = "English"
DEFAULT_COUNTRY_CODE = "us"
DEFAULT_WEBSOCKET_RENEWAL_DELAY = 43200 # 12 hours
sensors = {
# Sensor Name: [value field, device class, unit]
# Device state sensors
None: {
"TimeToEnd": [None, None, UnitOfTime.MINUTES],
"RunningTime": [None, None, UnitOfTime.MINUTES],
"CyclePhase": [None, None, None],
"CycleSubPhase": ["string", None, None],
"ApplianceState": [None, None, None],
"DisplayTemperature": ["container", SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS],
"DisplayFoodProbeTemperature": ["container", SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS],
"SensorTemperature": ["container", SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS],
"DefrostTemperature": ["container", SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS],
"TargetMicrowavePower": ["numberValue", SensorDeviceClass.ENERGY, UnitOfPower.WATT],
"OvenProcessIdentifier": ["valueTransl", None, None],
"RemoteControl": [None, None, None],
"DefaultExtraRinse": ["numberValue", None, None],
"TargetTemperature": ["container", SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS],
"StartTime": [None, None, UnitOfTime.MINUTES],
"AnalogTemperature": ["numberValue", SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS],
"WaterSoftenerMode": [None, None, None],
"SteamValue": ["valTransl", None, None],
"ELUXTimeManagerLevel": ["valTransl", None, None],
"AnalogSpinSpeed": ["valTransl", None, None],
"WaterTankWarningMode": [None, None, None],
"DryingTime": [None, None, None],
"HumidityTarget": ["valTransl", None, None],
"AntiCreaseValue": [None, None, None],
"DrynessValue": ["valTransl", None, None],
"ProgramUID": ["valTransl", None, None]
},
# Device diagnostic sensors
EntityCategory.DIAGNOSTIC : {
"SensorHumidity": ["numberValue", SensorDeviceClass.HUMIDITY, PERCENTAGE, None],
"AmbientTemperature": ["container", SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, None],
"ApplianceTotalWorkingTime": [None, None, UnitOfTime.MINUTES],
"TotalCycleCounter": [None, None, None],
"RinseAidLevel": [None, None, None],
"WaterHardness": ["valueTransl", None, None],
"FCTotalWashCyclesCount": [None, None, None],
"FCTotalWashingTime": [None, None, None],
"ApplianceMode": [None, None, None],
}
}
sensors_binary = {
# Sensor Name: [value field, device class, invert]
# Device state sensors
None: {
"DoorState": ["numberValue", BinarySensorDeviceClass.DOOR, None],
"DoorLock": ["numberValue", BinarySensorDeviceClass.LOCK, True],
"UiLockMode": ["numberValue", None, None],
"EndOfCycleSound": ["numberValue", None, None],
"DefaultSoftPlus": ["numberValue", None, None],
"PreWashPhase": ["numberValue", None, None],
"RinseHold": ["numberValue", None, None],
"NightCycle": ["numberValue", None, None],
"Stain": ["numberValue", None, None],
"WMEconomy": ["numberValue", None, None],
"AnticreaseWSteam": ["numberValue", None, None],
"AnticreaseNoSteam": ["numberValue", None, None],
"Refresh": ["numberValue", None, None],
"ReversePlus": ["numberValue", None, None],
"Delicate": ["numberValue", None, None],
"TDEnergyLabel": ["numberValue", None, None],
"TDEconomy_Eco": ["numberValue", None, None],
"TDEconomy_Night": ["numberValue", None, None],
},
# Device diagnostic sensors
EntityCategory.DIAGNOSTIC : {
"TankA_reserve": ["numberValue", BinarySensorDeviceClass.PROBLEM, False],
"TankB_reserve": ["numberValue", BinarySensorDeviceClass.PROBLEM, False],
}
}
# these are attributes that appear in the state file but not in the capabilities.
# defining them here and in the catalog will allow these devices to be added dynamically
STATIC_ATTRIBUTES = [
"connectivityState",
"networkInterface/linkQualityIndicator",
"applianceMode",
]
# Icon mappings for default executeCommands
icon_mapping = {
"0": "mdi:power-off",
"1": "mdi:power-on",
"2": "mdi:play",
"3": "mdi:stop",
"4": "mdi:pause",
"5": "mdi:play-pause",
"OFF": "mdi:power-off",
"ON": "mdi:power-on",
"START": "mdi:play",
"STOPRESET": "mdi:stop",
"PAUSE": "mdi:pause",
"RESUME": "mdi:play-pause",
}
# List of supported Mobile App languages (from https://emea-production.api.electrolux.net/masterdata-service/api/v1/languages)
# List of supported Mobile App languages
# refer to https://emea-production.api.electrolux.net/masterdata-service/api/v1/languages
languages = {
"български": "bul",
"český": "ces",
@@ -153,3 +82,24 @@ languages = {
"Türk": "tur",
"Ukrayna": "ukr",
}
# List of attributes to ignore and that won't be added as entities (regex format)
ATTRIBUTES_BLACKLIST: list[str] = ["^fCMiscellaneous.+",
"fcOptisenseLoadWeight.*",
"applianceCareAndMaintenance.*",
"applianceMainBoardSwVersion",
"coolingValveState",
"networkInterface",
"temperatureRepresentation",
]
ATTRIBUTES_WHITELIST: list[str] = [".*waterUsage",
".*tankAReserve",
".*tankBReserve"]
# Rules to simplify the naming of entities
RENAME_RULES: list[str] = [r"^userSelections\/[^_]+_", r"^userSelections\/",
r"^fCMiscellaneousState\/[^_]+_", r"^fCMiscellaneousState\/"]
# List of entity names that need to be updated to 0 manually when they are close to 0
TIME_ENTITIES_TO_UPDATE = ["timeToEnd"]
+214 -95
View File
@@ -1,130 +1,249 @@
from homeassistant.components.sensor import ENTITY_ID_FORMAT
"""Entity platform for Electrolux Status."""
import logging
from typing import Any, cast
from pyelectroluxocp import OneAppApi
from pyelectroluxocp.apiModels import ApplienceStatusResponse
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.helpers.entity import Entity
from . import ElectroluxStatusDataUpdateCoordinator
from .api import Appliance, ApplianceEntity
from .const import DOMAIN
from .model import ElectroluxDevice
_LOGGER: logging.Logger = logging.getLogger(__package__)
class ElectroluxStatusEntity(CoordinatorEntity):
def __init__(self, coordinator: ElectroluxStatusDataUpdateCoordinator, config_entry, pnc_id, entity_type, entity_attr, entity_source):
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Configure entity platform."""
coordinator = hass.data[DOMAIN][entry.entry_id]
if appliances := coordinator.data.get("appliances", None):
for appliance_id, appliance in appliances.appliances.items():
entities = [
entity
for entity in appliance.entities
if entity.entity_type == "entity"
]
_LOGGER.debug(
"Electrolux add %d entities to registry for appliance %s",
len(entities),
appliance_id,
)
async_add_entities(entities)
class ElectroluxEntity(CoordinatorEntity):
"""Class for Electorolux devices."""
_attr_has_entity_name = True
appliance_status: ApplienceStatusResponse
def __init__(
self,
coordinator: Any,
name: str,
config_entry,
pnc_id: str,
entity_type: Platform,
entity_name,
entity_attr,
entity_source,
capability: dict[str, Any],
unit: str,
device_class: str,
entity_category: EntityCategory,
icon: str,
catalog_entry: ElectroluxDevice | None = None,
) -> None:
"""Initaliaze the entity."""
super().__init__(coordinator)
self.api = coordinator.api
self.root_attribute = ["properties", "reported"]
self.data = None
self.coordinator = coordinator
self._cached_value = None
self._name = name
self._icon = icon
self._device_class = device_class
self._entity_category = entity_category
self._catalog_entry = catalog_entry
self.api: OneAppApi = coordinator.api
self.entity_name = entity_name
self.entity_attr = entity_attr
self.entity_type = entity_type
self.entity_source = entity_source
self.config_entry = config_entry
self.pnc_id = pnc_id
self.entity_id = ENTITY_ID_FORMAT.format(f"{self.get_appliance.brand}_{self.get_appliance.name}_{self.entity_source}_{self.entity_attr}")
self.unit = unit
self.capability = capability
self.entity_id = f"{self.entity_domain}.{self.get_appliance.brand}_{self.get_appliance.name}_{self.entity_source}_{self.entity_attr}"
if catalog_entry:
self.entity_registry_enabled_default = (
catalog_entry.entity_registry_enabled_default
)
_LOGGER.debug("Electrolux new entity %s for appliance %s", name, pnc_id)
def setup(self, data):
"""Initialiaze setup."""
self.data = data
@property
def name(self):
"""Return the name of the sensor."""
return self.get_entity.name
def entity_domain(self) -> str:
"""Enitity domain for the entry."""
return "sensor"
@property
def get_entity(self) -> ApplianceEntity:
return self.get_appliance.get_entity(self.entity_type, self.entity_attr, self.entity_source, None)
@property
def get_appliance(self) -> Appliance:
return self.coordinator.data['appliances'].get_appliance(self.pnc_id)
@property
def unique_id(self):
def unique_id(self) -> str:
"""Return a unique ID to use for this entity."""
return f"{self.config_entry.entry_id}-{self.entity_attr}-{self.entity_source}-{self.pnc_id}"
return f"{self.config_entry.entry_id}-{self.entity_attr}-{self.entity_source or 'root'}-{self.pnc_id}"
# Disabled this as this removes the value from display : there is no readonly property for entities
# @property
# def available(self) -> bool:
# if (self._entity_category == EntityCategory.DIAGNOSTIC
# or self.entity_attr in ALWAYS_ENABLED_ATTRIBUTES):
# return True
# connection_state = self.get_connection_state()
# if connection_state and connection_state != "disconnected":
# return True
# return False
@property
def should_poll(self) -> bool:
"""Confirm if device should be polled."""
return False
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
# _LOGGER.debug("Electrolux entity got data %s", self.coordinator.data)
if self.coordinator.data is None:
return
appliances = self.coordinator.data.get("appliances", None)
self.appliance_status = appliances.get_appliance(self.pnc_id).state
self.async_write_ha_state()
def get_connection_state(self) -> str | None:
"""Return connection state."""
if self.appliance_status:
return self.appliance_status.get("connectionState", None)
return None
def get_state_attr(self, path: str) -> str | None:
"""Return value of other appliance attributes.
Used for the evaluation of state_mapping one property to another.
"""
if "/" in path:
if self.reported_state.get(path, None):
return self.reported_state.get(path)
source, attr = path.split("/")
return self.reported_state.get(source, {}).get(attr, None)
return self.reported_state.get(path, None)
@property
def reported_state(self):
"""Return reported state of the appliance."""
return self.appliance_status.get("properties", {}).get("reported", {})
@property
def name(self) -> str:
"""Return the name of the sensor."""
if self.catalog_entry and self.catalog_entry.friendly_name:
return self.catalog_entry.friendly_name.capitalize()
return self._name
@property
def icon(self) -> str | None:
"""Return the icon of the entity."""
return self._icon
# @property
# def get_entity(self) -> ApplianceEntity:
# return self.get_appliance.get_entity(self.entity_type, self.entity_attr, self.entity_source, None)
@property
def get_appliance(self):
"""Return the appliance device."""
return self.coordinator.data["appliances"].get_appliance(self.pnc_id)
@property
def device_info(self):
"""Return identifiers of the device."""
return {
"identifiers": {(DOMAIN, self.get_appliance.name)},
"identifiers": {(DOMAIN, self.pnc_id)},
"name": self.get_appliance.name,
"model": self.get_appliance.model,
"manufacturer": self.get_appliance.brand,
}
@property
def extra_state_attributes(self):
"""Return the state attributes."""
return {
"integration": DOMAIN,
}
def entity_category(self) -> EntityCategory | None:
"""Return entity category."""
return self._entity_category
@property
def device_class(self):
"""Return the device class of the sensor."""
return self.get_entity.device_class
return self._device_class
def extract_value(self):
"""Return the appliance attributes of the entity."""
root_attribute = self.root_attribute
attribute = self.entity_attr
if self.appliance_status:
root = cast(any, self.appliance_status)
# Format returned by push is slightly different from format returned by API : fields are at root level
# Let's check if we can find the fields at root first
if (
self.entity_source and root.get(self.entity_source, None) is not None
) or root.get(attribute, None):
root_attribute = None
if root_attribute:
for item in root_attribute:
if root:
root = root.get(item, None)
if root:
if self.entity_source:
category: dict[str, Any] | None = root.get(self.entity_source, None)
if category:
return category.get(attribute)
else:
return root.get(attribute, None)
return None
def update(self, appliance_status: ApplienceStatusResponse):
"""Update the appliance status."""
self.appliance_status = appliance_status
# if self.hass:
# self.async_write_ha_state()
@property
def entity_category(self):
"""Return the entity category of the sensor."""
return self.get_entity.entity_category
class ElectroluxButtonEntity(Entity):
def __init__(self, coordinator, config_entry, pnc_id, entity_type, entity_attr, entity_source, val_to_send, icon):
self.coordinator = coordinator
self.entity_attr = entity_attr
self.entity_type = entity_type
self.entity_source = entity_source
self.config_entry = config_entry
self.pnc_id = pnc_id
self.val_to_send = val_to_send
self.button_icon = icon
self.entity_id = ENTITY_ID_FORMAT.format(f"{self.get_appliance.brand}_{self.get_appliance.name}_{self.entity_source}_{self.entity_attr}_{self.val_to_send}")
def json_path(self) -> str | None:
"""Return the path to the entry."""
if self.entity_source:
return f"{self.entity_source}/{self.entity_attr}"
return self.entity_attr
@property
def name(self):
"""Return the name of the button."""
return self.get_entity.name
def catalog_entry(self) -> ElectroluxDevice | None:
"""Return matched catalog entry."""
return self._catalog_entry
@property
def get_entity(self) -> ApplianceEntity:
return self.get_appliance.get_entity(self.entity_type, self.entity_attr, self.entity_source, self.val_to_send)
@property
def get_appliance(self) -> Appliance:
return self.coordinator.data['appliances'].get_appliance(self.pnc_id)
@property
def unique_id(self):
"""Return a unique ID to use for this entity."""
return f"{self.config_entry.entry_id}-{self.val_to_send}-{self.entity_attr}-{self.entity_source}-{self.pnc_id}"
@property
def device_info(self):
return {
"identifiers": {(DOMAIN, self.get_appliance.name)},
"name": self.get_appliance.name,
"model": self.get_appliance.model,
"manufacturer": self.get_appliance.brand,
}
@property
def extra_state_attributes(self):
"""Return the state attributes."""
return {
"integration": DOMAIN,
}
@property
def device_class(self):
"""Return the device class of the button."""
return self.get_entity.device_class
@property
def entity_category(self):
"""Return the entity category of the button."""
return self.get_entity.entity_category
@property
def icon(self):
"""Return the icon of the button."""
return self.button_icon
@property
def available(self):
# available state should depends on connect state
return True
# @property
# def extra_state_attributes(self) -> dict[str, Any]:
# """Return the state attributes of the sensor."""
# return {
# "Path": self.json_path,
# "entity_type": str(self.entity_type),
# "entity_category": str(self.entity_category),
# "device_class": str(self.device_class),
# "capability": str(self.capability),
# }
@@ -1,19 +1,19 @@
{
"domain": "electrolux_status",
"name": "Electrolux Care Integration",
"codeowners": [
"@mauro-midolo"
],
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/mauro-midolo/homeassistant_electrolux_status",
"homekit": {},
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/mauro-midolo/homeassistant_electrolux_status/issues",
"requirements": [
"pyelectroluxconnect==0.3.20"
],
"ssdp": [],
"version": "1.2.0",
"zeroconf": []
}
{
"domain": "electrolux_status",
"name": "Electrolux Status",
"codeowners": ["@albaintor","@kingy444"],
"config_flow": true,
"documentation": "https://github.com/albaintor/homeassistant_electrolux_status",
"homekit": {},
"integration_type": "hub",
"iot_class": "cloud_push",
"issue_tracker": "https://github.com/albaintor/homeassistant_electrolux_status/issues",
"loggers": ["pyelectroluxocp"],
"requirements": [
"pyelectroluxocp==0.1.3",
"aiofiles==24.1.0"
],
"ssdp": [],
"version": "2.2.0",
"zeroconf": []
}
@@ -1,9 +0,0 @@
import pyelectroluxconnect
class pyelectroluxconnect_util:
@staticmethod
def get_session(username, password, region="emea", language ="eng"):
return pyelectroluxconnect.Session(
username, password, deviceId="ElectroluxHomeAssistant", language=language, region=region,
)
+115 -24
View File
@@ -1,37 +1,128 @@
from typing import cast
"""Switch platform for Electrolux Status."""
from .api import ApplianceSensor
from .const import DOMAIN
from .const import SENSOR
from .entity import ElectroluxStatusEntity
import contextlib
import logging
from typing import Any
from . import ElectroluxStatusDataUpdateCoordinator
from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTemperature, UnitOfTime, UnitOfVolume
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, SENSOR
from .entity import ElectroluxEntity
from .util import create_notification
_LOGGER: logging.Logger = logging.getLogger(__package__)
async def async_setup_entry(hass, entry, async_add_devices):
"""Setup sensor platform."""
coordinator: ElectroluxStatusDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
appliances = coordinator.data.get('appliances', None)
if appliances is not None:
for appliance_id, appliance in appliances.found_appliances.items():
async_add_devices(
[
ElectroluxStatusSensor(coordinator, entry, appliance_id, entity.entity_type, entity.attr, entity.source)
for entity in appliance.entities if entity.entity_type == SENSOR
]
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Configure sensor platform."""
coordinator = hass.data[DOMAIN][entry.entry_id]
if appliances := coordinator.data.get("appliances", None):
for appliance_id, appliance in appliances.appliances.items():
entities = [
entity for entity in appliance.entities if entity.entity_type == SENSOR
]
_LOGGER.debug(
"Electrolux add %d SENSOR entities to registry for appliance %s",
len(entities),
appliance_id,
)
async_add_entities(entities)
class ElectroluxStatusSensor(ElectroluxStatusEntity, SensorEntity):
class ElectroluxSensor(ElectroluxEntity, SensorEntity):
"""Electrolux Status Sensor class."""
@property
def native_value(self):
"""Return the state of the sensor."""
return self.get_entity.state
def entity_domain(self):
"""Enitity domain for the entry. Used for consistent entity_id."""
return SENSOR
@property
def native_unit_of_measurement(self):
return cast(ApplianceSensor, self.get_entity).unit
def suggested_display_precision(self) -> int | None:
"""Get the display precision."""
if self.unit == UnitOfTemperature.CELSIUS:
return 2
if self.unit == UnitOfTemperature.FAHRENHEIT:
return 2
if self.unit == UnitOfVolume.LITERS:
return 0
if self.unit == UnitOfTime.SECONDS:
return 0
return None
@property
def native_value(self) -> str | int | float:
"""Return the state of the sensor."""
value = self.extract_value()
if self.capability.get("access") == "constant":
value = self.capability.get("default")
elif self.entity_attr == "alerts":
value = len(value) if value is not None else 0
elif value is not None and isinstance(self.unit, UnitOfTime):
# Electrolux bug - prevent negative/disabled timers
value = max(value, 0)
if self.catalog_entry and self.catalog_entry.value_mapping:
# Electrolux presents as string but returns an int
# the mapping entry allows us to correctly display this to the frontend
mapping = self.catalog_entry.value_mapping
_LOGGER.debug("Mapping %s: %s to %s", self.json_path, value, mapping)
if value in mapping:
value = mapping.get(value, value)
if isinstance(value, str):
if "_" in value:
value = value.replace("_", " ")
value = value.title()
if value is not None:
self._cached_value = value
else:
value = self._cached_value
return value
@property
def native_unit_of_measurement(self) -> str | None:
"""Return unit of measurement."""
# store the value of the sensor in the native format
return self.unit
@property
def suggested_unit_of_measurement(self) -> str | None:
"""Return suggested unit of measurement."""
# change the display unit in the HA frontend
if self.unit == UnitOfTime.SECONDS:
return UnitOfTime.MINUTES
with contextlib.suppress(ValueError):
if self._is_valid_suggested_unit(self.unit):
return self.unit
return None
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes of the sensor."""
if self.entity_attr == "alerts":
alert_types = self.capability.get("values", {})
# default is nullable - set a value for display to user
alert_types = {key: "OFF" for key in alert_types}
if current_alerts := self.extract_value():
for alert in current_alerts:
name = alert.get("code", "Unknown")
severity = alert.get("severity", "Alert")
status = alert.get("acknowledgeStatus", "")
alert_types[name] = f"{severity}-{status}"
create_notification(
self.hass,
self.config_entry,
alert_name=name,
alert_severity=severity,
alert_status=status,
title=self.name,
)
return alert_types
return {}
@@ -1,31 +1,41 @@
{
"config": {
"step": {
"user": {
"title": "Electrolux Status",
"description": "If you need help with the configuration have a look here: https://github.com/mauro-midolo/homeassistant_electrolux_status",
"data": {
"username": "Username",
"password": "Password",
"region": "Region (emea, apac, na, latam or frigidaire)",
"language": "Electrolux provided sensor names and values language"
"config": {
"abort": {
"already_configured": "Device is already configured",
"already_configured_account": "Account is already configured",
"reconfigure_successful": "Re-configuration was successful",
"single_instance_allowed": "Already configured. Only a single configuration possible."
},
"error": {
"invalid_auth": "Invalid authentication"
},
"step": {
"user": {
"data": {
"language": "Language",
"notifications": "Raise notifications for ALERT level notices",
"notifications_diagnostic": "Raise notifications for DIAGNOSTIC level notices",
"notifications_warning": "Raise notifications for WARNING level notices",
"password": "Password",
"username": "Username"
},
"description": "If you need help with the configuration visit: https://github.com/albaintor/homeassistant_electrolux_status",
"title": "Electrolux Status"
}
}
}
},
"error": {
"auth": "Username/Password is wrong."
},
"abort": {
"single_instance_allowed": "Only a single instance is allowed."
}
},
"options": {
"step": {
"user": {
"data": {
"scan_interval": "API update interval (seconds)"
"options": {
"step": {
"user": {
"data": {
"language": "Language",
"notifications": "Raise notifications for ALERT level notices",
"notifications_diagnostic": "Raise notifications for DIAGNOSTIC level notices",
"notifications_warning": "Raise notifications for WARNING level notices",
"password": "Password",
"renew_interval": "Renewal interval of websocket (seconds)"
}
}
}
}
}
}
}
@@ -1,31 +1,41 @@
{
"config": {
"step": {
"user": {
"title": "Electrolux Status",
"description": "Se hai bisogno di aiuto con la configurazione, dai un'occhiata qui: https://github.com/mauro-midolo/homeassistant_electrolux_status",
"data": {
"username": "Username",
"password": "Password",
"region": "Regione (emea, apac, na, latam o frigidaire)",
"language": "Lingua dei nomi e dei valori dei sensori forniti da Electrolux"
"config": {
"abort": {
"already_configured": "Il dispositivo è già configurato",
"already_configured_account": "L'account è già configurato",
"reconfigure_successful": "La riconfigurazione ha avuto successo",
"single_instance_allowed": "Già configurato.Solo una singola configurazione possibile."
},
"error": {
"invalid_auth": "Autenticazione non valida"
},
"step": {
"user": {
"data": {
"language": "Lingua",
"notifications": "Aumenta le notifiche per gli avvisi di livello di allerta",
"notifications_diagnostic": "Aumenta le notifiche per gli avvisi a livello diagnostico",
"notifications_warning": "Aumenta le notifiche per gli avvisi di livello di avvertimento",
"password": "Password",
"username": "Nome utente"
},
"description": "Se hai bisogno di aiuto con la visita di configurazione: https://github.com/albaintor/homeeasistant_electrolux_status",
"title": "Stato Electrolux"
}
}
}
},
"error": {
"auth": "Username o password errati."
},
"abort": {
"single_instance_allowed": "È consentita una sola istanza."
}
},
"options": {
"step": {
"user": {
"data": {
"scan_interval": "Intervallo di aggiornamento API in secondi"
"options": {
"step": {
"user": {
"data": {
"language": "Lingua",
"notifications": "Aumenta le notifiche per gli avvisi di livello di allerta",
"notifications_diagnostic": "Aumenta le notifiche per gli avvisi a livello diagnostico",
"notifications_warning": "Aumenta le notifiche per gli avvisi di livello di avvertimento",
"password": "Password",
"renew_interval": "Intervallo di rinnovo di WebSocket (secondi)"
}
}
}
}
}
}
}
@@ -1,31 +1,41 @@
{
"config": {
"step": {
"user": {
"title": "Electrolux Status",
"description": "Jeśli potrzebujesz pomocy przy konfiguracji, zerknij tutaj: https://github.com/mauro-midolo/homeassistant_electrolux_status",
"data": {
"username": "Nazwa użytkownika",
"password": "Hasło",
"region": "Region (emea, apac, na, latam or frigidaire)",
"language": "Język nazw czujników i wartości, dostarczony przez Electrolux."
"config": {
"abort": {
"already_configured": "Urządzenie jest już skonfigurowane",
"already_configured_account": "Konto jest już skonfigurowane",
"reconfigure_successful": "Ponowna konfiguracja zakończyła się powodzeniem",
"single_instance_allowed": "Już skonfigurowano. Możliwa tylko jedna konfiguracja."
},
"error": {
"invalid_auth": "Nieprawidłowe uwierzytelnienie"
},
"step": {
"user": {
"data": {
"language": "Język",
"notifications": "Pokazuj alerty",
"notifications_diagnostic": "Pokazuj powoadomienia diagnostyczne",
"notifications_warning": "Pokazuj powiadomienia ostrzegawcze",
"password": "Hasło",
"username": "Nazwa użytkownika"
},
"description": "Jeśli potrzebujesz pomocy w konfiguracji, odwiedź: https://github.com/albaintor/homeassistant_electrolux_status",
"title": "Electrolux Status"
}
}
}
},
"error": {
"auth": "Nazwa użytkownika lub hasło jest nieprawidłowe."
},
"abort": {
"single_instance_allowed": "Dozwolona jest tylko jedna instancja."
}
},
"options": {
"step": {
"user": {
"data": {
"scan_interval": "Interwał aktualizacji API (sekundy)"
"options": {
"step": {
"user": {
"data": {
"language": "Język",
"notifications": "Pokazuj alerty",
"notifications_diagnostic": "Pokazuj powoadomienia diagnostyczne",
"notifications_warning": "Pokazuj powiadomienia ostrzegawcze",
"password": "Hasło",
"renew_interval": "Okres odnawiania połączenia WebSocket (sekundy)"
}
}
}
}
}
}
}
@@ -1,31 +1,41 @@
{
"config": {
"step": {
"user": {
"title": "Electrolux Status",
"description": "Ak potrebujete pomoc s konfiguráciou, pozrite sa sem:https://github.com/mauro-midolo/homeassistant_electrolux_status",
"data": {
"username": "Užívateľské meno",
"password": "Heslo",
"region": "Región (emea, apac, na, latam alebo frigidaire)",
"language": "Electrolux poskytol názvy senzorov a jazyk hodnôt"
"config": {
"abort": {
"already_configured": "Zariadenie je už nakonfigurované",
"already_configured_account": "Účet je už nakonfigurovaný",
"reconfigure_successful": "Opätovná konfigurácia bola úspešná",
"single_instance_allowed": "Už nakonfigurované.Iba jedna možná konfigurácia."
},
"error": {
"invalid_auth": "Neplatné overovanie"
},
"step": {
"user": {
"data": {
"language": "Jazyk",
"notifications": "Zvýšiť oznámenia o upozorneniach na úrovni výstrahy",
"notifications_diagnostic": "Zvýšte upozornenia pre oznámenia o diagnostickej úrovni",
"notifications_warning": "Zvýšte oznámenia o upozorneniach na úrovni varovania",
"password": "Heslo",
"username": "Užívateľské meno"
},
"description": "Ak potrebujete pomoc s konfiguráciou, navštívte: https://github.com/albaintor/homeassistant_electrolux_status",
"title": "Status"
}
}
}
},
"error": {
"auth": "Používateľské meno/heslo je nesprávne."
},
"abort": {
"single_instance_allowed": "Povolený je len jeden prípad."
}
},
"options": {
"step": {
"user": {
"data": {
"scan_interval": "Interval aktualizácie API (sekundy)"
"options": {
"step": {
"user": {
"data": {
"language": "Jazyk",
"notifications": "Zvýšiť oznámenia o upozorneniach na úrovni výstrahy",
"notifications_diagnostic": "Zvýšte upozornenia pre oznámenia o diagnostickej úrovni",
"notifications_warning": "Zvýšte oznámenia o upozorneniach na úrovni varovania",
"password": "Heslo",
"renew_interval": "Interval obnovy WebSocket (sekundy)"
}
}
}
}
}
}
}
}
@@ -3,7 +3,7 @@ import asyncio
import logging
from gardena.exceptions.authentication_exception import AuthenticationException
from gardena.smart_system import SmartSystem
from gardena.smart_system import SmartSystem, get_ssl_context
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_CLIENT_ID,
@@ -25,17 +25,17 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
PLATFORMS = ["vacuum", "sensor", "switch", "binary_sensor"]
PLATFORMS = ["lawn_mower", "sensor", "switch", "binary_sensor", "button"]
# Create SSL context outside of event loop
_SSL_CONTEXT = get_ssl_context()
async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the Gardena Smart System integration."""
if DOMAIN not in hass.data:
hass.data[DOMAIN] = {}
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER.debug("Setting up Gardena Smart System component")
@@ -44,26 +44,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
client_id=entry.data[CONF_CLIENT_ID],
client_secret=entry.data[CONF_CLIENT_SECRET],
)
try:
await gardena_system.start()
except AccessDeniedError as ex:
_LOGGER.error('Got Access Denied Error when setting up Gardena Smart System: %s', ex)
return False
except InvalidClientError as ex:
_LOGGER.error('Got Invalid Client Error when setting up Gardena Smart System: %s', ex)
return False
except MissingTokenError as ex:
_LOGGER.error('Got Missing Token Error when setting up Gardena Smart System: %s', ex)
return False
while True:
try:
await gardena_system.start()
break # If connection is successful, return True
except ConnectionError:
await asyncio.sleep(60) # Wait for 60 seconds before trying to reconnect
except AccessDeniedError as ex:
_LOGGER.error('Got Access Denied Error when setting up Gardena Smart System: %s', ex)
return False
except InvalidClientError as ex:
_LOGGER.error('Got Invalid Client Error when setting up Gardena Smart System: %s', ex)
return False
except MissingTokenError as ex:
_LOGGER.error('Got Missing Token Error when setting up Gardena Smart System: %s', ex)
return False
hass.data[DOMAIN][GARDENA_SYSTEM] = gardena_system
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, lambda event: hass.async_create_task(gardena_system.stop()))
for component in PLATFORMS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, component))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
_LOGGER.debug("Gardena Smart System component setup finished")
return True
@@ -77,7 +78,9 @@ class GardenaSmartSystem:
self._hass = hass
self.smart_system = SmartSystem(
client_id=client_id,
client_secret=client_secret)
client_secret=client_secret,
ssl_context=_SSL_CONTEXT # Use the pre-created SSL context
)
async def start(self):
try:
@@ -1,16 +1,20 @@
"""Support for Gardena Smart System websocket connection status."""
from homeassistant.components.binary_sensor import (
DEVICE_CLASS_CONNECTIVITY,
BinarySensorDeviceClass,
BinarySensorEntity,
)
from custom_components.gardena_smart_system import GARDENA_SYSTEM
from .const import DOMAIN
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Perform the setup for Gardena websocket connection status."""
async_add_entities([SmartSystemWebsocketStatus(hass.data[DOMAIN][GARDENA_SYSTEM].smart_system)], True)
async_add_entities(
[SmartSystemWebsocketStatus(hass.data[DOMAIN][GARDENA_SYSTEM].smart_system)],
True,
)
class SmartSystemWebsocketStatus(BinarySensorEntity):
@@ -54,4 +58,4 @@ class SmartSystemWebsocketStatus(BinarySensorEntity):
@property
def device_class(self):
"""Return the class of this device, from component DEVICE_CLASSES."""
return DEVICE_CLASS_CONNECTIVITY
return BinarySensorDeviceClass.CONNECTIVITY
@@ -85,10 +85,6 @@ class GardenaSmartSystemConfigFlowHandler(config_entries.ConfigFlow, domain=DOMA
class GardenaSmartSystemOptionsFlowHandler(config_entries.OptionsFlow):
def __init__(self, config_entry):
"""Initialize Gardena Smart Sytem options flow."""
self.config_entry = config_entry
async def async_step_init(self, user_input=None):
"""Manage the options."""
return await self.async_step_user()
@@ -20,3 +20,5 @@ ATTR_OPERATING_HOURS = "operating_hours"
ATTR_LAST_ERROR = "last_error"
ATTR_ERROR = "error"
ATTR_STATE = "state"
ATTR_STINT_START = "stint_start"
ATTR_STINT_END = "stint_end"
@@ -1,11 +1,11 @@
{
"domain": "gardena_smart_system",
"name": "Gardena Smart System integration",
"version": "1.0.0",
"version": "1.2.1",
"config_flow": true,
"documentation": "https://github.com/py-smart-gardena/hass-gardena-smart-system",
"issue_tracker": "https://github.com/py-smart-gardena/hass-gardena-smart-system/issues",
"dependencies": [],
"codeowners": ["@py-smart-gardena"],
"requirements": ["py-smart-gardena==1.3.7"]
"requirements": ["py-smart-gardena==1.3.12", "oauthlib==3.2.2"]
}
@@ -31,5 +31,17 @@
"title": "Gardena Smart System - Options"
}
}
},
"services": {
"start_override": {
"name": "Start override",
"description": "Start the mower immediately for specific duration and override the schedule.",
"fields": {
"duration": {
"name": "Duration",
"description": "The duration in seconds."
}
}
}
}
}
}
@@ -32,5 +32,17 @@
"title": "Gardena Smart System - Options"
}
}
},
"services": {
"start_override": {
"name": "Start override",
"description": "Start the mower immediately for specific duration and override the schedule.",
"fields": {
"duration": {
"name": "Duration",
"description": "The duration in seconds."
}
}
}
}
}
@@ -19,10 +19,10 @@
"cleaning": "Mäht",
"docked": "Angedockt",
"error": "Fehler",
"idle": "[%key:common::state::idle%]",
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]",
"paused": "[%key:common::state::paused%]",
"idle": "Inaktiv",
"off": "Aus",
"on": "An",
"paused": "Pausiert",
"returning": "Zurück zum Dock"
}
}
@@ -1,222 +0,0 @@
"""Support for Gardena mower."""
import asyncio
import logging
from datetime import timedelta
from homeassistant.core import callback
from homeassistant.components.vacuum import (
StateVacuumEntity,
SUPPORT_BATTERY,
SUPPORT_RETURN_HOME,
SUPPORT_STATE,
SUPPORT_STOP,
SUPPORT_START,
STATE_PAUSED,
STATE_CLEANING,
STATE_DOCKED,
STATE_RETURNING,
STATE_ERROR,
ATTR_BATTERY_LEVEL,
)
from .const import (
ATTR_ACTIVITY,
ATTR_BATTERY_STATE,
ATTR_NAME,
ATTR_OPERATING_HOURS,
ATTR_RF_LINK_LEVEL,
ATTR_RF_LINK_STATE,
ATTR_SERIAL,
ATTR_LAST_ERROR,
ATTR_ERROR,
ATTR_STATE,
CONF_MOWER_DURATION,
DEFAULT_MOWER_DURATION,
DOMAIN,
GARDENA_LOCATION,
)
from .sensor import GardenaSensor
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(minutes=1)
SUPPORT_GARDENA = (
SUPPORT_BATTERY | SUPPORT_RETURN_HOME | SUPPORT_STOP | SUPPORT_START | SUPPORT_STATE
)
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Gardena smart mower system."""
entities = []
for mower in hass.data[DOMAIN][GARDENA_LOCATION].find_device_by_type("MOWER"):
entities.append(GardenaSmartMower(hass, mower, config_entry.options))
_LOGGER.debug("Adding mower as vacuums: %s", entities)
async_add_entities(entities, True)
class GardenaSmartMower(StateVacuumEntity):
"""Representation of a Gardena Connected Mower."""
def __init__(self, hass, mower, options):
"""Initialize the Gardena Connected Mower."""
self.hass = hass
self._device = mower
self._options = options
self._name = "{}".format(self._device.name)
self._unique_id = f"{self._device.serial}-mower"
self._state = None
self._error_message = ""
async def async_added_to_hass(self):
"""Subscribe to events."""
self._device.add_callback(self.update_callback)
@property
def should_poll(self) -> bool:
"""No polling needed for a vacuum."""
return False
def update_callback(self, device):
"""Call update for Home Assistant when the device is updated."""
self.schedule_update_ha_state(True)
async def async_update(self):
"""Update the states of Gardena devices."""
_LOGGER.debug("Running Gardena update")
# Managing state
state = self._device.state
_LOGGER.debug("Mower has state %s", state)
if state in ["WARNING", "ERROR", "UNAVAILABLE"]:
_LOGGER.debug("Mower has an error")
self._state = STATE_ERROR
self._error_message = self._device.last_error_code
else:
_LOGGER.debug("Getting mower state")
activity = self._device.activity
_LOGGER.debug("Mower has activity %s", activity)
if activity == "PAUSED":
self._state = STATE_PAUSED
elif activity in [
"OK_CUTTING",
"OK_CUTTING_TIMER_OVERRIDDEN",
"OK_LEAVING",
]:
self._state = STATE_CLEANING
elif activity == "OK_SEARCHING":
self._state = STATE_RETURNING
elif activity in [
"OK_CHARGING",
"PARKED_TIMER",
"PARKED_PARK_SELECTED",
"PARKED_AUTOTIMER",
]:
self._state = STATE_DOCKED
elif activity == "NONE":
self._state = None
_LOGGER.debug("Mower has no activity")
@property
def name(self):
"""Return the name of the device."""
return self._device.name
@property
def supported_features(self):
"""Flag lawn mower robot features that are supported."""
return SUPPORT_GARDENA
@property
def battery_level(self):
"""Return the battery level of the lawn mower."""
return self._device.battery_level
@property
def state(self):
"""Return the status of the lawn mower."""
return self._state
@property
def available(self):
"""Return True if the device is available."""
return self._device.state != "UNAVAILABLE"
def error(self):
"""Return the error message."""
if self._state == STATE_ERROR:
return self._error_message
return ""
@property
def extra_state_attributes(self):
"""Return the state attributes of the lawn mower."""
return {
ATTR_ACTIVITY: self._device.activity,
ATTR_BATTERY_LEVEL: self._device.battery_level,
ATTR_BATTERY_STATE: self._device.battery_state,
ATTR_RF_LINK_LEVEL: self._device.rf_link_level,
ATTR_RF_LINK_STATE: self._device.rf_link_state,
ATTR_OPERATING_HOURS: self._device.operating_hours,
ATTR_LAST_ERROR: self._device.last_error_code,
ATTR_ERROR: "NONE" if self._device.activity != "NONE" else self._device.last_error_code,
ATTR_STATE: self._device.activity if self._device.activity != "NONE" else self._device.last_error_code
}
@property
def option_mower_duration(self) -> int:
return self._options.get(CONF_MOWER_DURATION, DEFAULT_MOWER_DURATION)
def start(self):
"""Start the mower using Gardena API command START_SECONDS_TO_OVERRIDE. Duration is read from integration options."""
duration = self.option_mower_duration * 60
_LOGGER.debug("Mower command: vacuum.start => START_SECONDS_TO_OVERRIDE, %s", duration)
return asyncio.run_coroutine_threadsafe(
self._device.start_seconds_to_override(duration), self.hass.loop
).result()
def stop(self, **kwargs):
"""Stop the mower using Gardena API command PARK_UNTIL_FURTHER_NOTICE."""
_LOGGER.debug("Mower command: vacuum.stop => PARK_UNTIL_FURTHER_NOTICE")
asyncio.run_coroutine_threadsafe(
self._device.park_until_further_notice(), self.hass.loop
).result()
def turn_on(self, **kwargs):
"""Start the mower using Gardena API command START_DONT_OVERRIDE."""
_LOGGER.debug("Mower command: vacuum.turn_on => START_DONT_OVERRIDE")
asyncio.run_coroutine_threadsafe(
self._device.start_dont_override(), self.hass.loop
).result()
def turn_off(self, **kwargs):
"""Stop the mower using Gardena API command PARK_UNTIL_FURTHER_NOTICE."""
_LOGGER.debug("Mower command: vacuum.turn_off => PARK_UNTIL_FURTHER_NOTICE")
asyncio.run_coroutine_threadsafe(
self._device.park_until_further_notice(), self.hass.loop
).result()
def return_to_base(self, **kwargs):
"""Stop the mower using Gardena API command PARK_UNTIL_NEXT_TASK."""
_LOGGER.debug("Mower command: vacuum.return_to_base => PARK_UNTIL_NEXT_TASK")
asyncio.run_coroutine_threadsafe(
self._device.park_until_next_task(), self.hass.loop
).result()
@property
def unique_id(self) -> str:
"""Return a unique ID."""
return self._unique_id
@property
def device_info(self):
return {
"identifiers": {
# Serial numbers are unique identifiers within a specific domain
(DOMAIN, self._device.serial)
},
"name": self._device.name,
"manufacturer": "Gardena",
"model": self._device.model_type,
}
+46 -111
View File
@@ -1,80 +1,58 @@
"""
HACS gives you a powerful UI to handle downloads of all your custom needs.
"""HACS gives you a powerful UI to handle downloads of all your custom needs.
For more details about this integration, please refer to the documentation at
https://hacs.xyz/
"""
from __future__ import annotations
import os
from typing import Any
from __future__ import annotations
from aiogithubapi import AIOGitHubAPIException, GitHub, GitHubAPI
from aiogithubapi.const import ACCEPT_HEADERS
from awesomeversion import AwesomeVersion
from homeassistant.components.frontend import async_remove_panel
from homeassistant.components.lovelace.system_health import system_health_info
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import Platform, __version__ as HAVERSION
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.start import async_at_start
from homeassistant.loader import async_get_integration
import voluptuous as vol
from .base import HacsBase
from .const import DOMAIN, MINIMUM_HA_VERSION, STARTUP
from .const import DOMAIN, HACS_SYSTEM_ID, MINIMUM_HA_VERSION, STARTUP
from .data_client import HacsDataClient
from .enums import ConfigurationType, HacsDisabledReason, HacsStage, LovelaceMode
from .enums import HacsDisabledReason, HacsStage, LovelaceMode
from .frontend import async_register_frontend
from .utils.configuration_schema import hacs_config_combined
from .utils.data import HacsData
from .utils.logger import LOGGER
from .utils.queue_manager import QueueManager
from .utils.version import version_left_higher_or_equal_then_right
from .websocket import async_register_websocket_commands
CONFIG_SCHEMA = vol.Schema({DOMAIN: hacs_config_combined()}, extra=vol.ALLOW_EXTRA)
PLATFORMS = [Platform.SWITCH, Platform.UPDATE]
async def async_initialize_integration(
async def _async_initialize_integration(
hass: HomeAssistant,
*,
config_entry: ConfigEntry | None = None,
config: dict[str, Any] | None = None,
config_entry: ConfigEntry,
) -> bool:
"""Initialize the integration"""
hass.data[DOMAIN] = hacs = HacsBase()
hacs.enable_hacs()
if config is not None:
if DOMAIN not in config:
return True
if hacs.configuration.config_type == ConfigurationType.CONFIG_ENTRY:
return True
hacs.configuration.update_from_dict(
{
"config_type": ConfigurationType.YAML,
**config[DOMAIN],
"config": config[DOMAIN],
}
)
if config_entry.source == SOURCE_IMPORT:
# Import is not supported
hass.async_create_task(hass.config_entries.async_remove(config_entry.entry_id))
return False
if config_entry is not None:
if config_entry.source == SOURCE_IMPORT:
hass.async_create_task(hass.config_entries.async_remove(config_entry.entry_id))
return False
hacs.configuration.update_from_dict(
{
"config_entry": config_entry,
"config_type": ConfigurationType.CONFIG_ENTRY,
**config_entry.data,
**config_entry.options,
}
)
hacs.configuration.update_from_dict(
{
"config_entry": config_entry,
**config_entry.data,
**config_entry.options,
},
)
integration = await async_get_integration(hass, DOMAIN)
@@ -104,7 +82,6 @@ async def async_initialize_integration(
except BaseException: # lgtm [py/catch-base-exception] pylint: disable=broad-except
# If this happens, the users YAML is not valid, we assume YAML mode
pass
hacs.log.debug("Configuration type: %s", hacs.configuration.config_type)
hacs.core.config_path = hacs.hass.config.path()
if hacs.core.ha_version is None:
@@ -131,19 +108,18 @@ async def async_initialize_integration(
"""HACS startup tasks."""
hacs.enable_hacs()
for location in (
hass.config.path("custom_components/custom_updater.py"),
hass.config.path("custom_components/custom_updater/__init__.py"),
):
if os.path.exists(location):
hacs.log.critical(
"This cannot be used with custom_updater. "
"To use this you need to remove custom_updater form %s",
location,
)
try:
import custom_components.custom_updater
except ImportError:
pass
else:
hacs.log.critical(
"HACS cannot be used with custom_updater. "
"To use HACS you need to remove custom_updater from `custom_components`",
)
hacs.disable_hacs(HacsDisabledReason.CONSTRAINS)
return False
hacs.disable_hacs(HacsDisabledReason.CONSTRAINS)
return False
if not version_left_higher_or_equal_then_right(
hacs.core.ha_version.string,
@@ -160,39 +136,23 @@ async def async_initialize_integration(
hacs.disable_hacs(HacsDisabledReason.RESTORE)
return False
if not hacs.configuration.experimental:
can_update = await hacs.async_can_update()
hacs.log.debug("Can update %s repositories", can_update)
hacs.set_active_categories()
async_register_websocket_commands(hass)
async_register_frontend(hass, hacs)
await async_register_frontend(hass, hacs)
if hacs.configuration.config_type == ConfigurationType.YAML:
hass.async_create_task(
async_load_platform(hass, Platform.SENSOR, DOMAIN, {}, hacs.configuration.config)
)
hacs.log.info("Update entities are only supported when using UI configuration")
else:
await hass.config_entries.async_forward_entry_setups(
config_entry,
[Platform.SENSOR, Platform.UPDATE]
if hacs.configuration.experimental
else [Platform.SENSOR],
)
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
hacs.set_stage(HacsStage.SETUP)
if hacs.system.disabled:
return False
# Schedule startup tasks
async_at_start(hass=hass, at_start_cb=hacs.startup_tasks)
hacs.set_stage(HacsStage.WAITING)
hacs.log.info("Setup complete, waiting for Home Assistant before startup tasks starts")
# Schedule startup tasks
async_at_start(hass=hass, at_start_cb=hacs.startup_tasks)
return not hacs.system.disabled
async def async_try_startup(_=None):
@@ -202,10 +162,7 @@ async def async_initialize_integration(
except AIOGitHubAPIException:
startup_result = False
if not startup_result:
if (
hacs.configuration.config_type == ConfigurationType.YAML
or hacs.system.disabled_reason != HacsDisabledReason.INVALID_TOKEN
):
if hacs.system.disabled_reason != HacsDisabledReason.INVALID_TOKEN:
hacs.log.info("Could not setup HACS, trying again in 15 min")
async_call_later(hass, 900, async_try_startup)
return
@@ -213,37 +170,19 @@ async def async_initialize_integration(
await async_try_startup()
# Remove old (v0-v1) sensor if it exists, can be removed in v3
er = async_get_entity_registry(hass)
if old_sensor := er.async_get_entity_id("sensor", DOMAIN, HACS_SYSTEM_ID):
er.async_remove(old_sensor)
# Mischief managed!
return True
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
"""Set up this integration using yaml."""
if DOMAIN in config:
async_create_issue(
hass,
DOMAIN,
"deprecated_yaml_configuration",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml_configuration",
learn_more_url="https://hacs.xyz/docs/configuration/options",
)
LOGGER.warning(
"YAML configuration of HACS is deprecated and will be "
"removed in version 2.0.0, there will be no automatic "
"import of this. "
"Please remove it from your configuration, "
"restart Home Assistant and use the UI to configure it instead."
)
return await async_initialize_integration(hass=hass, config=config)
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Set up this integration using UI."""
config_entry.async_on_unload(config_entry.add_update_listener(async_reload_entry))
setup_result = await async_initialize_integration(hass=hass, config_entry=config_entry)
setup_result = await _async_initialize_integration(hass=hass, config_entry=config_entry)
hacs: HacsBase = hass.data[DOMAIN]
return setup_result and not hacs.system.disabled
@@ -259,7 +198,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
# Clear out pending queue
hacs.queue.clear()
for task in hacs.recuring_tasks:
for task in hacs.recurring_tasks:
# Cancel all pending tasks
task()
@@ -269,15 +208,11 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
try:
if hass.data.get("frontend_panels", {}).get("hacs"):
hacs.log.info("Removing sidepanel")
hass.components.frontend.async_remove_panel("hacs")
async_remove_panel(hass, "hacs")
except AttributeError:
pass
platforms = ["sensor"]
if hacs.configuration.experimental:
platforms.append("update")
unload_ok = await hass.config_entries.async_unload_platforms(config_entry, platforms)
unload_ok = await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
hacs.set_stage(None)
hacs.disable_hacs(HacsDisabledReason.REMOVED)
+189 -254
View File
@@ -1,16 +1,17 @@
"""Base HACS class."""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import asdict, dataclass, field
from datetime import timedelta
import gzip
import logging
import math
import os
import pathlib
import shutil
from typing import TYPE_CHECKING, Any, Awaitable, Callable
from typing import TYPE_CHECKING, Any
from aiogithubapi import (
AIOGitHubAPIException,
@@ -24,23 +25,22 @@ from aiogithubapi import (
from aiogithubapi.objects.repository import AIOGitHubAPIRepository
from aiohttp.client import ClientSession, ClientTimeout
from awesomeversion import AwesomeVersion
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.components.persistent_notification import (
async_create as async_create_persistent_notification,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.loader import Integration
from homeassistant.util import dt
from custom_components.hacs.repositories.base import (
HACS_MANIFEST_KEYS_TO_EXPORT,
REPOSITORY_KEYS_TO_EXPORT,
)
from .const import DOMAIN, TV, URL_BASE
from .coordinator import HacsUpdateCoordinator
from .data_client import HacsDataClient
from .enums import (
ConfigurationType,
HacsCategory,
HacsDisabledReason,
HacsDispatchEvent,
@@ -58,12 +58,14 @@ from .exceptions import (
HacsRepositoryExistException,
HomeAssistantCoreRepositoryException,
)
from .repositories import RERPOSITORY_CLASSES
from .utils.decode import decode_content
from .repositories import REPOSITORY_CLASSES
from .repositories.base import HACS_MANIFEST_KEYS_TO_EXPORT, REPOSITORY_KEYS_TO_EXPORT
from .utils.file_system import async_exists
from .utils.json import json_loads
from .utils.logger import LOGGER
from .utils.queue_manager import QueueManager
from .utils.store import async_load_from_store, async_save_to_store
from .utils.workarounds import async_register_static_path
if TYPE_CHECKING:
from .repositories.base import HacsRepository
@@ -113,15 +115,11 @@ class HacsConfiguration:
appdaemon: bool = False
config: dict[str, Any] = field(default_factory=dict)
config_entry: ConfigEntry | None = None
config_type: ConfigurationType | None = None
country: str = "ALL"
debug: bool = False
dev: bool = False
experimental: bool = False
frontend_repo_url: str = ""
frontend_repo: str = ""
netdaemon_path: str = "netdaemon/apps/"
netdaemon: bool = False
plugin_path: str = "www/community/"
python_script_path: str = "python_scripts/"
python_script: bool = False
@@ -142,6 +140,8 @@ class HacsConfiguration:
raise HacsException("Configuration is not valid.")
for key in data:
if key in {"experimental", "netdaemon", "release_limit", "debug"}:
continue
self.__setattr__(key, data[key])
@@ -217,6 +217,13 @@ class HacsRepositories:
"""Return a list of downloaded repositories."""
return [repo for repo in self._repositories if repo.data.installed]
def category_downloaded(self, category: HacsCategory) -> bool:
"""Check if a given category has been downloaded."""
for repository in self.list_downloaded:
if repository.data.category == category:
return True
return False
def register(self, repository: HacsRepository, default: bool = False) -> None:
"""Register a repository."""
repo_id = str(repository.data.id)
@@ -348,9 +355,6 @@ class HacsRepositories:
class HacsBase:
"""Base HACS class."""
common = HacsCommon()
configuration = HacsConfiguration()
core = HacsCore()
data: HacsData | None = None
data_client: HacsDataClient | None = None
frontend_version: str | None = None
@@ -358,17 +362,24 @@ class HacsBase:
githubapi: GitHubAPI | None = None
hass: HomeAssistant | None = None
integration: Integration | None = None
log: logging.Logger = LOGGER
queue: QueueManager | None = None
recuring_tasks = []
repositories: HacsRepositories = HacsRepositories()
repository: AIOGitHubAPIRepository | None = None
session: ClientSession | None = None
stage: HacsStage | None = None
status = HacsStatus()
system = HacsSystem()
validation: ValidationManager | None = None
version: str | None = None
version: AwesomeVersion | None = None
def __init__(self) -> None:
"""Initialize."""
self.common = HacsCommon()
self.configuration = HacsConfiguration()
self.coordinators: dict[HacsCategory, HacsUpdateCoordinator] = {}
self.core = HacsCore()
self.log = LOGGER
self.recurring_tasks: list[Callable[[], None]] = []
self.repositories = HacsRepositories()
self.status = HacsStatus()
self.system = HacsSystem()
@property
def integration_dir(self) -> pathlib.Path:
@@ -394,12 +405,7 @@ class HacsBase:
if reason != HacsDisabledReason.REMOVED:
self.log.error("HACS is disabled - %s", reason)
if (
reason == HacsDisabledReason.INVALID_TOKEN
and self.configuration.config_type == ConfigurationType.CONFIG_ENTRY
):
self.configuration.config_entry.state = ConfigEntryState.SETUP_ERROR
self.configuration.config_entry.reason = "Authentication failed"
if reason == HacsDisabledReason.INVALID_TOKEN:
self.hass.add_job(self.configuration.config_entry.async_start_reauth, self.hass)
def enable_hacs(self) -> None:
@@ -413,12 +419,14 @@ class HacsBase:
if category not in self.common.categories:
self.log.info("Enable category: %s", category)
self.common.categories.add(category)
self.coordinators[category] = HacsUpdateCoordinator()
def disable_hacs_category(self, category: HacsCategory) -> None:
"""Disable HACS category."""
if category in self.common.categories:
self.log.info("Disabling category: %s", category)
self.common.categories.pop(category)
self.coordinators.pop(category)
async def async_save_file(self, file_path: str, content: Any) -> bool:
"""Save a file."""
@@ -451,12 +459,13 @@ class HacsBase:
try:
await self.hass.async_add_executor_job(_write_file)
except (
BaseException # lgtm [py/catch-base-exception] pylint: disable=broad-except
# lgtm [py/catch-base-exception] pylint: disable=broad-except
BaseException
) as error:
self.log.error("Could not write data to %s - %s", file_path, error)
return False
return os.path.exists(file_path)
return await async_exists(self.hass, file_path)
async def async_can_update(self) -> int:
"""Helper to calculate the number of repositories we can fetch data for."""
@@ -472,24 +481,13 @@ class HacsBase:
)
self.disable_hacs(HacsDisabledReason.RATE_LIMIT)
except (
BaseException # lgtm [py/catch-base-exception] pylint: disable=broad-except
# lgtm [py/catch-base-exception] pylint: disable=broad-except
BaseException
) as exception:
self.log.exception(exception)
return 0
async def async_github_get_hacs_default_file(self, filename: str) -> list:
"""Get the content of a default file."""
response = await self.async_github_api_method(
method=self.githubapi.repos.contents.get,
repository=HacsGitHubRepo.DEFAULT,
path=filename,
)
if response is None:
return []
return json_loads(decode_content(response.data.content))
async def async_github_api_method(
self,
method: Callable[[], Awaitable[TV]],
@@ -513,7 +511,8 @@ class HacsBase:
except GitHubException as exception:
_exception = exception
except (
BaseException # lgtm [py/catch-base-exception] pylint: disable=broad-except
# lgtm [py/catch-base-exception] pylint: disable=broad-except
BaseException
) as exception:
self.log.exception(exception)
_exception = exception
@@ -545,7 +544,7 @@ class HacsBase:
):
raise AddonRepositoryException()
if category not in RERPOSITORY_CLASSES:
if category not in REPOSITORY_CLASSES:
self.log.warning(
"%s is not a valid repository category, %s will not be registered.",
category,
@@ -556,7 +555,7 @@ class HacsBase:
if (renamed := self.common.renamed_repositories.get(repository_full_name)) is not None:
repository_full_name = renamed
repository: HacsRepository = RERPOSITORY_CLASSES[category](self, repository_full_name)
repository: HacsRepository = REPOSITORY_CLASSES[category](self, repository_full_name)
if check:
try:
await repository.async_registration(ref)
@@ -566,7 +565,8 @@ class HacsBase:
self.log.error("Validation for %s failed.", repository_full_name)
if self.system.action:
raise HacsException(
f"::error:: Validation for {repository_full_name} failed."
f"::error:: Validation for {
repository_full_name} failed."
)
return repository.validate.errors
if self.system.action:
@@ -582,7 +582,8 @@ class HacsBase:
except AIOGitHubAPIException as exception:
self.common.skip.add(repository.data.full_name)
raise HacsException(
f"Validation for {repository_full_name} failed with {exception}."
f"Validation for {
repository_full_name} failed with {exception}."
) from exception
if self.status.new:
@@ -592,7 +593,7 @@ class HacsBase:
repository.data.id = repository_id
else:
if self.hass is not None and ((check and repository.data.new) or self.status.new):
if self.hass is not None and check and repository.data.new:
self.async_dispatch(
HacsDispatchEvent.REPOSITORY,
{
@@ -613,91 +614,84 @@ class HacsBase:
for repo in critical:
if not repo["acknowledged"]:
self.log.critical("URGENT!: Check the HACS panel!")
self.hass.components.persistent_notification.create(
title="URGENT!", message="**Check the HACS panel!**"
async_create_persistent_notification(
self.hass, title="URGENT!", message="**Check the HACS panel!**"
)
break
if not self.configuration.experimental:
self.recuring_tasks.append(
self.hass.helpers.event.async_track_time_interval(
self.async_update_downloaded_repositories, timedelta(hours=48)
)
)
self.recuring_tasks.append(
self.hass.helpers.event.async_track_time_interval(
self.async_update_all_repositories,
timedelta(hours=96),
)
)
else:
self.recuring_tasks.append(
self.hass.helpers.event.async_track_time_interval(
self.async_load_hacs_from_github,
timedelta(hours=48),
)
)
self.recuring_tasks.append(
self.hass.helpers.event.async_track_time_interval(
self.async_update_downloaded_custom_repositories, timedelta(hours=48)
self.recurring_tasks.append(
async_track_time_interval(
self.hass,
self.async_load_hacs_from_github,
timedelta(hours=48),
)
)
self.recuring_tasks.append(
self.hass.helpers.event.async_track_time_interval(
self.async_get_all_category_repositories, timedelta(hours=6)
self.recurring_tasks.append(
async_track_time_interval(
self.hass, self.async_update_downloaded_custom_repositories, timedelta(hours=48)
)
)
self.recuring_tasks.append(
self.hass.helpers.event.async_track_time_interval(
self.async_check_rate_limit, timedelta(minutes=5)
)
)
self.recuring_tasks.append(
self.hass.helpers.event.async_track_time_interval(
self.async_prosess_queue, timedelta(minutes=10)
self.recurring_tasks.append(
async_track_time_interval(
self.hass, self.async_get_all_category_repositories, timedelta(hours=6)
)
)
self.recuring_tasks.append(
self.hass.helpers.event.async_track_time_interval(
self.async_handle_critical_repositories, timedelta(hours=6)
self.recurring_tasks.append(
async_track_time_interval(self.hass, self.async_check_rate_limit, timedelta(minutes=5))
)
self.recurring_tasks.append(
async_track_time_interval(self.hass, self.async_process_queue, timedelta(minutes=10))
)
self.recurring_tasks.append(
async_track_time_interval(
self.hass, self.async_handle_critical_repositories, timedelta(hours=6)
)
)
self.hass.bus.async_listen_once(
unsub = self.hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_FINAL_WRITE, self.data.async_force_write
)
if config_entry := self.configuration.config_entry:
config_entry.async_on_unload(unsub)
self.log.debug("There are %s scheduled recurring tasks", len(self.recuring_tasks))
self.log.debug("There are %s scheduled recurring tasks", len(self.recurring_tasks))
self.status.startup = False
self.async_dispatch(HacsDispatchEvent.STATUS, {})
await self.async_handle_removed_repositories()
await self.async_get_all_category_repositories()
await self.async_update_downloaded_repositories()
self.set_stage(HacsStage.RUNNING)
self.async_dispatch(HacsDispatchEvent.RELOAD, {"force": True})
await self.async_handle_critical_repositories()
await self.async_prosess_queue()
await self.async_process_queue()
self.async_dispatch(HacsDispatchEvent.STATUS, {})
async def async_download_file(self, url: str, *, headers: dict | None = None) -> bytes | None:
async def async_download_file(
self,
url: str,
*,
headers: dict | None = None,
keep_url: bool = False,
nolog: bool = False,
**_,
) -> bytes | None:
"""Download files, and return the content."""
if url is None:
return None
if "tags/" in url:
if not keep_url and "tags/" in url:
url = url.replace("tags/", "")
self.log.debug("Downloading %s", url)
self.log.debug("Trying to download %s", url)
timeouts = 0
while timeouts < 5:
@@ -713,9 +707,10 @@ class HacsBase:
return await request.read()
raise HacsException(
f"Got status code {request.status} when trying to download {url}"
f"Got status code {
request.status} when trying to download {url}"
)
except asyncio.TimeoutError:
except TimeoutError:
self.log.warning(
"A timeout of 60! seconds was encountered while downloading %s, "
"using over 60 seconds to download a single file is not normal. "
@@ -731,23 +726,34 @@ class HacsBase:
continue
except (
BaseException # lgtm [py/catch-base-exception] pylint: disable=broad-except
# lgtm [py/catch-base-exception] pylint: disable=broad-except
BaseException
) as exception:
self.log.exception("Download failed - %s", exception)
if not nolog:
self.log.exception("Download failed - %s", exception)
return None
async def async_recreate_entities(self) -> None:
"""Recreate entities."""
if self.configuration == ConfigurationType.YAML or not self.configuration.experimental:
return
platforms = [Platform.UPDATE]
platforms = [Platform.SENSOR, Platform.UPDATE]
await self.hass.config_entries.async_unload_platforms(
entry=self.configuration.config_entry,
platforms=platforms,
)
# Workaround for core versions without https://github.com/home-assistant/core/pull/117084
if self.core.ha_version < AwesomeVersion("2024.6.0"):
unload_platforms_lock = asyncio.Lock()
async with unload_platforms_lock:
on_unload = self.configuration.config_entry._on_unload
self.configuration.config_entry._on_unload = []
await self.hass.config_entries.async_unload_platforms(
entry=self.configuration.config_entry,
platforms=platforms,
)
self.configuration.config_entry._on_unload = on_unload
else:
await self.hass.config_entries.async_unload_platforms(
entry=self.configuration.config_entry,
platforms=platforms,
)
await self.hass.config_entries.async_forward_entry_setups(
self.configuration.config_entry, platforms
)
@@ -760,49 +766,40 @@ class HacsBase:
def set_active_categories(self) -> None:
"""Set the active categories."""
self.common.categories = set()
for category in (HacsCategory.INTEGRATION, HacsCategory.PLUGIN):
for category in (HacsCategory.INTEGRATION, HacsCategory.PLUGIN, HacsCategory.TEMPLATE):
self.enable_hacs_category(HacsCategory(category))
if self.configuration.experimental and self.core.ha_version >= "2023.4.0b0":
self.enable_hacs_category(HacsCategory.TEMPLATE)
if HacsCategory.PYTHON_SCRIPT in self.hass.config.components:
if (
HacsCategory.PYTHON_SCRIPT in self.hass.config.components
or self.repositories.category_downloaded(HacsCategory.PYTHON_SCRIPT)
):
self.enable_hacs_category(HacsCategory.PYTHON_SCRIPT)
if self.hass.services.has_service("frontend", "reload_themes"):
if self.hass.services.has_service(
"frontend", "reload_themes"
) or self.repositories.category_downloaded(HacsCategory.THEME):
self.enable_hacs_category(HacsCategory.THEME)
if self.configuration.appdaemon:
self.enable_hacs_category(HacsCategory.APPDAEMON)
if self.configuration.netdaemon:
downloaded_netdaemon = [
x
for x in self.repositories.list_downloaded
if x.data.category == HacsCategory.NETDAEMON
]
if len(downloaded_netdaemon) != 0:
self.log.warning(
"NetDaemon in HACS is deprectaded. It will stop working in the future. "
"Please remove all your current NetDaemon repositories from HACS "
"and download them manually if you want to continue using them."
)
self.enable_hacs_category(HacsCategory.NETDAEMON)
async def async_load_hacs_from_github(self, _=None) -> None:
"""Load HACS from GitHub."""
if self.configuration.experimental and self.status.inital_fetch_done:
if self.status.inital_fetch_done:
return
try:
repository = self.repositories.get_by_full_name(HacsGitHubRepo.INTEGRATION)
should_recreate_entities = False
if repository is None:
should_recreate_entities = True
await self.async_register_repository(
repository_full_name=HacsGitHubRepo.INTEGRATION,
category=HacsCategory.INTEGRATION,
default=True,
)
repository = self.repositories.get_by_full_name(HacsGitHubRepo.INTEGRATION)
elif self.configuration.experimental and not self.status.startup:
elif not self.status.startup:
self.log.error("Scheduling update of hacs/integration")
self.queue.add(repository.common_update())
if repository is None:
@@ -813,6 +810,9 @@ class HacsBase:
repository.data.new = False
repository.data.releases = True
if should_recreate_entities:
await self.async_recreate_entities()
self.repository = repository.repository_object
self.repositories.mark_default(repository)
except HacsException as exception:
@@ -832,8 +832,6 @@ class HacsBase:
await asyncio.gather(
*[
self.async_get_category_repositories_experimental(category)
if self.configuration.experimental
else self.async_get_category_repositories(HacsCategory(category))
for category in self.common.categories or []
]
)
@@ -842,7 +840,7 @@ class HacsBase:
"""Update all category repositories."""
self.log.debug("Fetching updated content for %s", category)
try:
category_data = await self.data_client.get_data(category)
category_data = await self.data_client.get_data(category, validate=True)
except HacsNotModifiedException:
self.log.debug("No updates for %s", category)
return
@@ -853,14 +851,14 @@ class HacsBase:
await self.data.register_unknown_repositories(category_data, category)
for repo_id, repo_data in category_data.items():
repo = repo_data["full_name"]
if self.common.renamed_repositories.get(repo):
repo = self.common.renamed_repositories[repo]
if self.repositories.is_removed(repo):
repo_name = repo_data["full_name"]
if self.common.renamed_repositories.get(repo_name):
repo_name = self.common.renamed_repositories[repo_name]
if self.repositories.is_removed(repo_name):
continue
if repo in self.common.archived_repositories:
if repo_name in self.common.archived_repositories:
continue
if repository := self.repositories.get_by_full_name(repo):
if repository := self.repositories.get_by_full_name(repo_name):
self.repositories.set_repository_id(repository, repo_id)
self.repositories.mark_default(repository)
if repository.data.last_fetched is None or (
@@ -871,15 +869,6 @@ class HacsBase:
repository.repository_manifest.update_data(
{**dict(HACS_MANIFEST_KEYS_TO_EXPORT), **manifest}
)
self.async_dispatch(
HacsDispatchEvent.REPOSITORY,
{
"id": 1337,
"action": "update",
"repository": repository.data.full_name,
"repository_id": repository.data.id,
},
)
if category == "integration":
self.status.inital_fetch_done = True
@@ -896,50 +885,8 @@ class HacsBase:
)
self.repositories.unregister(repository)
async def async_get_category_repositories(self, category: HacsCategory) -> None:
"""Get repositories from category."""
if self.system.disabled:
return
try:
repositories = await self.async_github_get_hacs_default_file(category)
except HacsException:
return
for repo in repositories:
if self.common.renamed_repositories.get(repo):
repo = self.common.renamed_repositories[repo]
if self.repositories.is_removed(repo):
continue
if repo in self.common.archived_repositories:
continue
repository = self.repositories.get_by_full_name(repo)
if repository is not None:
self.repositories.mark_default(repository)
if self.status.new and self.configuration.dev:
# Force update for new installations
self.queue.add(repository.common_update())
continue
self.queue.add(
self.async_register_repository(
repository_full_name=repo,
category=category,
default=True,
)
)
async def async_update_all_repositories(self, _=None) -> None:
"""Update all repositories."""
if self.system.disabled:
return
self.log.debug("Starting recurring background task for all repositories")
for repository in self.repositories.list_all:
if repository.data.category in self.common.categories:
self.queue.add(repository.common_update())
self.async_dispatch(HacsDispatchEvent.REPOSITORY, {"action": "reload"})
self.log.debug("Recurring background task for all repositories done")
self.async_dispatch(HacsDispatchEvent.REPOSITORY, {})
self.coordinators[category].async_update_listeners()
async def async_check_rate_limit(self, _=None) -> None:
"""Check rate limit."""
@@ -951,9 +898,9 @@ class HacsBase:
self.log.debug("Ratelimit indicate we can update %s", can_update)
if can_update > 0:
self.enable_hacs()
await self.async_prosess_queue()
await self.async_process_queue()
async def async_prosess_queue(self, _=None) -> None:
async def async_process_queue(self, _=None) -> None:
"""Process the queue."""
if self.system.disabled:
self.log.debug("HACS is disabled")
@@ -993,12 +940,7 @@ class HacsBase:
self.log.info("Loading removed repositories")
try:
if self.configuration.experimental:
removed_repositories = await self.data_client.get_data("removed")
else:
removed_repositories = await self.async_github_get_hacs_default_file(
HacsCategory.REMOVED
)
removed_repositories = await self.data_client.get_data("removed", validate=True)
except HacsException:
return
@@ -1013,21 +955,20 @@ class HacsBase:
continue
if repository.data.installed:
if removed.removal_type != "critical":
if self.configuration.experimental:
async_create_issue(
hass=self.hass,
domain=DOMAIN,
issue_id=f"removed_{repository.data.id}",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="removed",
translation_placeholders={
"name": repository.data.full_name,
"reason": removed.reason,
"repositry_id": repository.data.id,
},
)
async_create_issue(
hass=self.hass,
domain=DOMAIN,
issue_id=f"removed_{repository.data.id}",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="removed",
translation_placeholders={
"name": repository.data.full_name,
"reason": removed.reason,
"repositry_id": repository.data.id,
},
)
self.log.warning(
"You have '%s' installed with HACS "
"this repository has been removed from HACS, please consider removing it. "
@@ -1042,30 +983,43 @@ class HacsBase:
if need_to_save:
await self.data.async_write()
async def async_update_downloaded_repositories(self, _=None) -> None:
"""Execute the task."""
if self.system.disabled or self.configuration.experimental:
return
self.log.info("Starting recurring background task for downloaded repositories")
for repository in self.repositories.list_downloaded:
if repository.data.category in self.common.categories:
self.queue.add(repository.update_repository(ignore_issues=True))
self.log.debug("Recurring background task for downloaded repositories done")
async def async_update_downloaded_custom_repositories(self, _=None) -> None:
"""Execute the task."""
if self.system.disabled or not self.configuration.experimental:
if self.system.disabled:
return
self.log.info("Starting recurring background task for downloaded custom repositories")
repositories_to_update = 0
repositories_updated = asyncio.Event()
async def update_repository(repository: HacsRepository) -> None:
"""Update a repository"""
nonlocal repositories_to_update
await repository.update_repository(ignore_issues=True)
repositories_to_update -= 1
if not repositories_to_update:
repositories_updated.set()
for repository in self.repositories.list_downloaded:
if (
repository.data.category in self.common.categories
and not self.repositories.is_default(repository.data.id)
):
self.queue.add(repository.update_repository(ignore_issues=True))
repositories_to_update += 1
self.queue.add(update_repository(repository))
async def update_coordinators() -> None:
"""Update all coordinators."""
await repositories_updated.wait()
for coordinator in self.coordinators.values():
coordinator.async_update_listeners()
if config_entry := self.configuration.config_entry:
config_entry.async_create_background_task(
self.hass, update_coordinators(), "update_coordinators"
)
else:
self.hass.async_create_background_task(update_coordinators(), "update_coordinators")
self.log.debug("Recurring background task for downloaded custom repositories done")
@@ -1077,10 +1031,7 @@ class HacsBase:
was_installed = False
try:
if self.configuration.experimental:
critical = await self.data_client.get_data("critical")
else:
critical = await self.async_github_get_hacs_default_file("critical")
critical = await self.data_client.get_data("critical", validate=True)
except (GitHubNotModifiedException, HacsNotModifiedException):
return
except HacsException:
@@ -1134,11 +1085,10 @@ class HacsBase:
self.log.critical("Restarting Home Assistant")
self.hass.async_create_task(self.hass.async_stop(100))
@callback
def async_setup_frontend_endpoint_plugin(self) -> None:
async def async_setup_frontend_endpoint_plugin(self) -> None:
"""Setup the http endpoints for plugins if its not already handled."""
if self.status.active_frontend_endpoint_plugin or not os.path.exists(
self.hass.config.path("www/community")
if self.status.active_frontend_endpoint_plugin or not await async_exists(
self.hass, self.hass.config.path("www/community")
):
return
@@ -1150,26 +1100,11 @@ class HacsBase:
use_cache,
)
self.hass.http.register_static_path(
await async_register_static_path(
self.hass,
URL_BASE,
self.hass.config.path("www/community"),
cache_headers=use_cache,
)
self.status.active_frontend_endpoint_plugin = True
@callback
def async_setup_frontend_endpoint_themes(self) -> None:
"""Setup the http endpoints for themes if its not already handled."""
if (
self.configuration.experimental
or self.status.active_frontend_endpoint_theme
or not os.path.exists(self.hass.config.path("themes"))
):
return
self.log.info("Setting up themes endpoint")
# Register themes
self.hass.http.register_static_path(f"{URL_BASE}/themes", self.hass.config.path("themes"))
self.status.active_frontend_endpoint_theme = True
+76 -75
View File
@@ -1,29 +1,32 @@
"""Adds config flow for HACS."""
from __future__ import annotations
import asyncio
from contextlib import suppress
from typing import TYPE_CHECKING
from aiogithubapi import GitHubDeviceAPI, GitHubException
from aiogithubapi import (
GitHubDeviceAPI,
GitHubException,
GitHubLoginDeviceModel,
GitHubLoginOauthModel,
)
from aiogithubapi.common.const import OAUTH_USER_LOGIN
from awesomeversion import AwesomeVersion
from homeassistant import config_entries
from homeassistant.config_entries import ConfigFlow, OptionsFlow
from homeassistant.const import __version__ as HAVERSION
from homeassistant.core import callback
from homeassistant.data_entry_flow import UnknownFlow
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.event import async_call_later
from homeassistant.loader import async_get_integration
import voluptuous as vol
from .base import HacsBase
from .const import CLIENT_ID, DOMAIN, LOCALE, MINIMUM_HA_VERSION
from .enums import ConfigurationType
from .utils.configuration_schema import (
APPDAEMON,
COUNTRY,
DEBUG,
EXPERIMENTAL,
NETDAEMON,
RELEASE_LIMIT,
SIDEPANEL_ICON,
SIDEPANEL_TITLE,
)
@@ -33,23 +36,22 @@ if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
class HacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
class HacsFlowHandler(ConfigFlow, domain=DOMAIN):
"""Config flow for HACS."""
hass: HomeAssistant
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
def __init__(self):
hass: HomeAssistant
activation_task: asyncio.Task | None = None
device: GitHubDeviceAPI | None = None
_registration: GitHubLoginDeviceModel | None = None
_activation: GitHubLoginOauthModel | None = None
_reauth: bool = False
def __init__(self) -> None:
"""Initialize."""
self._errors = {}
self.device = None
self.activation = None
self.log = LOGGER
self._progress_task = None
self._login_device = None
self._reauth = False
self._user_input = {}
async def async_step_user(self, user_input):
@@ -69,48 +71,55 @@ class HacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
return await self.async_step_device(user_input)
## Initial form
# Initial form
return await self._show_config_form(user_input)
async def async_step_device(self, _user_input):
"""Handle device steps"""
"""Handle device steps."""
async def _wait_for_activation(_=None):
if self._login_device is None or self._login_device.expires_in is None:
async_call_later(self.hass, 1, _wait_for_activation)
return
async def _wait_for_activation() -> None:
try:
response = await self.device.activation(device_code=self._registration.device_code)
self._activation = response.data
finally:
response = await self.device.activation(device_code=self._login_device.device_code)
self.activation = response.data
self.hass.async_create_task(
self.hass.config_entries.flow.async_configure(flow_id=self.flow_id)
)
async def _progress():
with suppress(UnknownFlow):
await self.hass.config_entries.flow.async_configure(flow_id=self.flow_id)
if not self.activation:
if not self.device:
integration = await async_get_integration(self.hass, DOMAIN)
if not self.device:
self.device = GitHubDeviceAPI(
client_id=CLIENT_ID,
session=aiohttp_client.async_get_clientsession(self.hass),
**{"client_name": f"HACS/{integration.version}"},
)
async_call_later(self.hass, 1, _wait_for_activation)
self.device = GitHubDeviceAPI(
client_id=CLIENT_ID,
session=aiohttp_client.async_get_clientsession(self.hass),
**{"client_name": f"HACS/{integration.version}"},
)
try:
response = await self.device.register()
self._login_device = response.data
return self.async_show_progress(
step_id="device",
progress_action="wait_for_device",
description_placeholders={
"url": OAUTH_USER_LOGIN,
"code": self._login_device.user_code,
},
)
self._registration = response.data
except GitHubException as exception:
self.log.error(exception)
return self.async_abort(reason="github")
LOGGER.exception(exception)
return self.async_abort(reason="could_not_register")
return self.async_show_progress_done(next_step_id="device_done")
if self.activation_task is None:
self.activation_task = self.hass.async_create_task(_wait_for_activation())
if self.activation_task.done():
if (exception := self.activation_task.exception()) is not None:
LOGGER.exception(exception)
return self.async_show_progress_done(next_step_id="could_not_register")
return self.async_show_progress_done(next_step_id="device_done")
show_progress_kwargs = {
"step_id": "device",
"progress_action": "wait_for_device",
"description_placeholders": {
"url": OAUTH_USER_LOGIN,
"code": self._registration.user_code,
},
"progress_task": self.activation_task,
}
return self.async_show_progress(**show_progress_kwargs)
async def _show_config_form(self, user_input):
"""Show the configuration form to edit location data."""
@@ -133,9 +142,6 @@ class HacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"acc_untested", default=user_input.get("acc_untested", False)
): bool,
vol.Required("acc_disable", default=user_input.get("acc_disable", False)): bool,
vol.Optional(
"experimental", default=user_input.get("experimental", False)
): bool,
}
),
errors=self._errors,
@@ -146,7 +152,7 @@ class HacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
if self._reauth:
existing_entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
self.hass.config_entries.async_update_entry(
existing_entry, data={**existing_entry.data, "token": self.activation.access_token}
existing_entry, data={**existing_entry.data, "token": self._activation.access_token}
)
await self.hass.config_entries.async_reload(existing_entry.entry_id)
return self.async_abort(reason="reauth_successful")
@@ -154,13 +160,17 @@ class HacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
return self.async_create_entry(
title="",
data={
"token": self.activation.access_token,
"token": self._activation.access_token,
},
options={
"experimental": self._user_input.get("experimental", False),
"experimental": True,
},
)
async def async_step_could_not_register(self, _user_input=None):
"""Handle issues that need transition await from progress step."""
return self.async_abort(reason="could_not_register")
async def async_step_reauth(self, _user_input=None):
"""Perform reauth upon an API authentication error."""
return await self.async_step_reauth_confirm()
@@ -181,12 +191,13 @@ class HacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
return HacsOptionsFlowHandler(config_entry)
class HacsOptionsFlowHandler(config_entries.OptionsFlow):
class HacsOptionsFlowHandler(OptionsFlow):
"""HACS config flow options handler."""
def __init__(self, config_entry):
"""Initialize HACS options flow."""
self.config_entry = config_entry
if AwesomeVersion(HAVERSION) < "2024.11.99":
self.config_entry = config_entry
async def async_step_init(self, _user_input=None):
"""Manage the options."""
@@ -196,10 +207,7 @@ class HacsOptionsFlowHandler(config_entries.OptionsFlow):
"""Handle a flow initialized by the user."""
hacs: HacsBase = self.hass.data.get(DOMAIN)
if user_input is not None:
limit = int(user_input.get(RELEASE_LIMIT, 5))
if limit <= 0 or limit > 100:
return self.async_abort(reason="release_limit_value")
return self.async_create_entry(title="", data=user_input)
return self.async_create_entry(title="", data={**user_input, "experimental": True})
if hacs is None or hacs.configuration is None:
return self.async_abort(reason="not_setup")
@@ -207,18 +215,11 @@ class HacsOptionsFlowHandler(config_entries.OptionsFlow):
if hacs.queue.has_pending_tasks:
return self.async_abort(reason="pending_tasks")
if hacs.configuration.config_type == ConfigurationType.YAML:
schema = {vol.Optional("not_in_use", default=""): str}
else:
schema = {
vol.Optional(SIDEPANEL_TITLE, default=hacs.configuration.sidepanel_title): str,
vol.Optional(SIDEPANEL_ICON, default=hacs.configuration.sidepanel_icon): str,
vol.Optional(RELEASE_LIMIT, default=hacs.configuration.release_limit): int,
vol.Optional(COUNTRY, default=hacs.configuration.country): vol.In(LOCALE),
vol.Optional(APPDAEMON, default=hacs.configuration.appdaemon): bool,
vol.Optional(NETDAEMON, default=hacs.configuration.netdaemon): bool,
vol.Optional(DEBUG, default=hacs.configuration.debug): bool,
vol.Optional(EXPERIMENTAL, default=hacs.configuration.experimental): bool,
}
schema = {
vol.Optional(SIDEPANEL_TITLE, default=hacs.configuration.sidepanel_title): str,
vol.Optional(SIDEPANEL_ICON, default=hacs.configuration.sidepanel_icon): str,
vol.Optional(COUNTRY, default=hacs.configuration.country): vol.In(LOCALE),
vol.Optional(APPDAEMON, default=hacs.configuration.appdaemon): bool,
}
return self.async_show_form(step_id="user", data_schema=vol.Schema(schema))
+2 -1
View File
@@ -1,4 +1,5 @@
"""Constants for HACS"""
from typing import TypeVar
from aiogithubapi.common.const import ACCEPT_HEADERS
@@ -6,7 +7,7 @@ from aiogithubapi.common.const import ACCEPT_HEADERS
NAME_SHORT = "HACS"
DOMAIN = "hacs"
CLIENT_ID = "395a8e669c5de9f7c6e8"
MINIMUM_HA_VERSION = "2023.6.0"
MINIMUM_HA_VERSION = "2024.4.1"
URL_BASE = "/hacsfiles"
+44 -3
View File
@@ -1,12 +1,25 @@
"""HACS Data client."""
from __future__ import annotations
import asyncio
from typing import Any
from aiohttp import ClientSession, ClientTimeout
import voluptuous as vol
from .exceptions import HacsException, HacsNotModifiedException
from .utils.logger import LOGGER
from .utils.validate import (
VALIDATE_FETCHED_V2_CRITICAL_REPO_SCHEMA,
VALIDATE_FETCHED_V2_REMOVED_REPO_SCHEMA,
VALIDATE_FETCHED_V2_REPO_DATA,
)
CRITICAL_REMOVED_VALIDATORS = {
"critical": VALIDATE_FETCHED_V2_CRITICAL_REPO_SCHEMA,
"removed": VALIDATE_FETCHED_V2_REMOVED_REPO_SCHEMA,
}
class HacsDataClient:
@@ -39,7 +52,7 @@ class HacsDataClient:
response.raise_for_status()
except HacsNotModifiedException:
raise
except asyncio.TimeoutError:
except TimeoutError:
raise HacsException("Timeout of 60s reached") from None
except Exception as exception:
raise HacsException(f"Error fetching data from HACS: {exception}") from exception
@@ -48,9 +61,37 @@ class HacsDataClient:
return await response.json()
async def get_data(self, section: str | None) -> dict[str, dict[str, Any]]:
async def get_data(self, section: str | None, *, validate: bool) -> dict[str, dict[str, Any]]:
"""Get data."""
return await self._do_request(filename="data.json", section=section)
data = await self._do_request(filename="data.json", section=section)
if not validate:
return data
if section in VALIDATE_FETCHED_V2_REPO_DATA:
validated = {}
for key, repo_data in data.items():
try:
validated[key] = VALIDATE_FETCHED_V2_REPO_DATA[section](repo_data)
except vol.Invalid as exception:
LOGGER.info(
"Got invalid data for %s (%s)", repo_data.get("full_name", key), exception
)
continue
return validated
if not (validator := CRITICAL_REMOVED_VALIDATORS.get(section)):
raise ValueError(f"Do not know how to validate {section}")
validated = []
for repo_data in data:
try:
validated.append(validator(repo_data))
except vol.Invalid as exception:
LOGGER.info("Got invalid data for %s (%s)", section, exception)
continue
return validated
async def get_repositories(self, section: str) -> list[str]:
"""Get repositories."""
+2 -4
View File
@@ -1,4 +1,5 @@
"""Diagnostics support for HACS."""
from __future__ import annotations
from typing import Any
@@ -10,7 +11,6 @@ from homeassistant.core import HomeAssistant
from .base import HacsBase
from .const import DOMAIN
from .utils.configuration_schema import TOKEN
async def async_get_config_entry_diagnostics(
@@ -48,8 +48,6 @@ async def async_get_config_entry_diagnostics(
"country",
"debug",
"dev",
"experimental",
"netdaemon",
"python_script",
"release_limit",
"theme",
@@ -79,4 +77,4 @@ async def async_get_config_entry_diagnostics(
except GitHubException as exception:
data["rate_limit"] = str(exception)
return async_redact_data(data, (TOKEN,))
return async_redact_data(data, ("token",))
+36 -12
View File
@@ -1,4 +1,5 @@
"""HACS Base entities."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
@@ -7,8 +8,10 @@ from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.update_coordinator import BaseCoordinatorEntity
from .const import DOMAIN, HACS_SYSTEM_ID, NAME_SHORT
from .coordinator import HacsUpdateCoordinator
from .enums import HacsDispatchEvent, HacsGitHubRepo
if TYPE_CHECKING:
@@ -39,6 +42,10 @@ class HacsBaseEntity(Entity):
"""Initialize."""
self.hacs = hacs
class HacsDispatcherEntity(HacsBaseEntity):
"""Base HACS entity listening to dispatcher signals."""
async def async_added_to_hass(self) -> None:
"""Register for status events."""
self.async_on_remove(
@@ -64,7 +71,7 @@ class HacsBaseEntity(Entity):
self.async_write_ha_state()
class HacsSystemEntity(HacsBaseEntity):
class HacsSystemEntity(HacsDispatcherEntity):
"""Base system entity."""
_attr_icon = "hacs:hacs"
@@ -76,7 +83,7 @@ class HacsSystemEntity(HacsBaseEntity):
return system_info(self.hacs)
class HacsRepositoryEntity(HacsBaseEntity):
class HacsRepositoryEntity(BaseCoordinatorEntity[HacsUpdateCoordinator], HacsBaseEntity):
"""Base repository entity."""
def __init__(
@@ -85,9 +92,11 @@ class HacsRepositoryEntity(HacsBaseEntity):
repository: HacsRepository,
) -> None:
"""Initialize."""
super().__init__(hacs=hacs)
BaseCoordinatorEntity.__init__(self, hacs.coordinators[repository.data.category])
HacsBaseEntity.__init__(self, hacs=hacs)
self.repository = repository
self._attr_unique_id = str(repository.data.id)
self._repo_last_fetched = repository.data.last_fetched
@property
def available(self) -> bool:
@@ -100,20 +109,35 @@ class HacsRepositoryEntity(HacsBaseEntity):
if self.repository.data.full_name == HacsGitHubRepo.INTEGRATION:
return system_info(self.hacs)
def _manufacturer():
if authors := self.repository.data.authors:
return ", ".join(author.replace("@", "") for author in authors)
return self.repository.data.full_name.split("/")[0]
return {
"identifiers": {(DOMAIN, str(self.repository.data.id))},
"name": self.repository.display_name,
"model": self.repository.data.category,
"manufacturer": ", ".join(
author.replace("@", "") for author in self.repository.data.authors
),
"configuration_url": "homeassistant://hacs",
"manufacturer": _manufacturer(),
"configuration_url": f"homeassistant://hacs/repository/{self.repository.data.id}",
"entry_type": DeviceEntryType.SERVICE,
}
@callback
def _update_and_write_state(self, data: dict) -> None:
"""Update the entity and write state."""
if data.get("repository_id") == self.repository.data.id:
self._update()
self.async_write_ha_state()
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if (
self._repo_last_fetched is not None
and self.repository.data.last_fetched is not None
and self._repo_last_fetched >= self.repository.data.last_fetched
):
return
self._repo_last_fetched = self.repository.data.last_fetched
self.async_write_ha_state()
async def async_update(self) -> None:
"""Update the entity.
Only used by the generic entity update service.
"""
+2 -21
View File
@@ -1,20 +1,7 @@
"""Helper constants."""
# pylint: disable=missing-class-docstring
import sys
if sys.version_info.minor >= 11:
# Needs Python 3.11
from enum import StrEnum # # pylint: disable=no-name-in-module
else:
try:
# https://github.com/home-assistant/core/blob/dev/homeassistant/backports/enum.py
# Considered internal to Home Assistant, can be removed whenever.
from homeassistant.backports.enum import StrEnum
except ImportError:
from enum import Enum
class StrEnum(str, Enum):
pass
from enum import StrEnum
class HacsGitHubRepo(StrEnum):
@@ -29,7 +16,6 @@ class HacsCategory(StrEnum):
INTEGRATION = "integration"
LOVELACE = "lovelace"
PLUGIN = "plugin" # Kept for legacy purposes
NETDAEMON = "netdaemon"
PYTHON_SCRIPT = "python_script"
TEMPLATE = "template"
THEME = "theme"
@@ -59,11 +45,6 @@ class RepositoryFile(StrEnum):
MAINIFEST_JSON = "manifest.json"
class ConfigurationType(StrEnum):
YAML = "yaml"
CONFIG_ENTRY = "config_entry"
class LovelaceMode(StrEnum):
"""Lovelace Modes."""
+23 -31
View File
@@ -1,61 +1,53 @@
""""Starting setup task: Frontend"."""
"""Starting setup task: Frontend."""
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from homeassistant.core import HomeAssistant, callback
from homeassistant.components.frontend import (
add_extra_js_url,
async_register_built_in_panel,
)
from .const import DOMAIN, URL_BASE
from .hacs_frontend import VERSION as FE_VERSION, locate_dir
from .hacs_frontend_experimental import (
VERSION as EXPERIMENTAL_FE_VERSION,
locate_dir as experimental_locate_dir,
)
from .utils.workarounds import async_register_static_path
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
from .base import HacsBase
@callback
def async_register_frontend(hass: HomeAssistant, hacs: HacsBase) -> None:
async def async_register_frontend(hass: HomeAssistant, hacs: HacsBase) -> None:
"""Register the frontend."""
# Setup themes endpoint if needed
hacs.async_setup_frontend_endpoint_themes()
# Register frontend
if hacs.configuration.dev and (frontend_path := os.getenv("HACS_FRONTEND_DIR")):
hacs.log.warning(
"<HacsFrontend> Frontend development mode enabled. Do not run in production!"
)
hass.http.register_static_path(
f"{URL_BASE}/frontend", f"{frontend_path}/hacs_frontend", cache_headers=False
)
elif hacs.configuration.experimental:
hacs.log.info("<HacsFrontend> Using experimental frontend")
hass.http.register_static_path(
f"{URL_BASE}/frontend", experimental_locate_dir(), cache_headers=False
await async_register_static_path(
hass, f"{URL_BASE}/frontend", f"{frontend_path}/hacs_frontend", cache_headers=False
)
hacs.frontend_version = "dev"
else:
#
hass.http.register_static_path(f"{URL_BASE}/frontend", locate_dir(), cache_headers=False)
await async_register_static_path(
hass, f"{URL_BASE}/frontend", locate_dir(), cache_headers=False
)
hacs.frontend_version = FE_VERSION
# Custom iconset
hass.http.register_static_path(
f"{URL_BASE}/iconset.js", str(hacs.integration_dir / "iconset.js")
)
if "frontend_extra_module_url" not in hass.data:
hass.data["frontend_extra_module_url"] = set()
hass.data["frontend_extra_module_url"].add(f"{URL_BASE}/iconset.js")
hacs.frontend_version = (
FE_VERSION if not hacs.configuration.experimental else EXPERIMENTAL_FE_VERSION
await async_register_static_path(
hass, f"{URL_BASE}/iconset.js", str(hacs.integration_dir / "iconset.js")
)
add_extra_js_url(hass, f"{URL_BASE}/iconset.js")
# Add to sidepanel if needed
if DOMAIN not in hass.data.get("frontend_panels", {}):
hass.components.frontend.async_register_built_in_panel(
async_register_built_in_panel(
hass,
component_name="custom",
sidebar_title=hacs.configuration.sidepanel_title,
sidebar_icon=hacs.configuration.sidepanel_icon,
@@ -72,4 +64,4 @@ def async_register_frontend(hass: HomeAssistant, hacs: HacsBase) -> None:
)
# Setup plugin endpoint if needed
hacs.async_setup_frontend_endpoint_plugin()
await hacs.async_setup_frontend_endpoint_plugin()
File diff suppressed because one or more lines are too long
@@ -1,23 +0,0 @@
import{a as t,r as i,n as a}from"./main-ad130be7.js";import{L as n,s}from"./c.82eccc94.js";let r=t([a("ha-list-item")],(function(t,a){return{F:class extends a{constructor(...i){super(...i),t(this)}},d:[{kind:"get",static:!0,key:"styles",value:function(){return[s,i`
:host {
padding-left: var(--mdc-list-side-padding, 20px);
padding-right: var(--mdc-list-side-padding, 20px);
}
:host([graphic="avatar"]:not([twoLine])),
:host([graphic="icon"]:not([twoLine])) {
height: 48px;
}
span.material-icons:first-of-type {
margin-inline-start: 0px !important;
margin-inline-end: var(
--mdc-list-item-graphic-margin,
16px
) !important;
direction: var(--direction);
}
span.material-icons:last-of-type {
margin-inline-start: auto !important;
margin-inline-end: 0px !important;
direction: var(--direction);
}
`]}}]}}),n);const e=t=>`https://brands.home-assistant.io/${t.useFallback?"_/":""}${t.domain}/${t.darkOptimized?"dark_":""}${t.type}.png`,o=t=>t.split("/")[4],p=t=>t.startsWith("https://brands.home-assistant.io/");export{r as H,e as b,o as e,p as i};
@@ -1,24 +0,0 @@
import{a as e,h as t,Y as i,e as n,i as o,$ as r,L as l,N as a,r as d,n as s}from"./main-ad130be7.js";import"./c.9b92f489.js";e([s("ha-button-menu")],(function(e,t){class s extends t{constructor(...t){super(...t),e(this)}}return{F:s,d:[{kind:"field",key:i,value:void 0},{kind:"field",decorators:[n()],key:"corner",value:()=>"TOP_START"},{kind:"field",decorators:[n()],key:"menuCorner",value:()=>"START"},{kind:"field",decorators:[n({type:Number})],key:"x",value:()=>null},{kind:"field",decorators:[n({type:Number})],key:"y",value:()=>null},{kind:"field",decorators:[n({type:Boolean})],key:"multi",value:()=>!1},{kind:"field",decorators:[n({type:Boolean})],key:"activatable",value:()=>!1},{kind:"field",decorators:[n({type:Boolean})],key:"disabled",value:()=>!1},{kind:"field",decorators:[n({type:Boolean})],key:"fixed",value:()=>!1},{kind:"field",decorators:[o("mwc-menu",!0)],key:"_menu",value:void 0},{kind:"get",key:"items",value:function(){var e;return null===(e=this._menu)||void 0===e?void 0:e.items}},{kind:"get",key:"selected",value:function(){var e;return null===(e=this._menu)||void 0===e?void 0:e.selected}},{kind:"method",key:"focus",value:function(){var e,t;null!==(e=this._menu)&&void 0!==e&&e.open?this._menu.focusItemAtIndex(0):null===(t=this._triggerButton)||void 0===t||t.focus()}},{kind:"method",key:"render",value:function(){return r`
<div @click=${this._handleClick}>
<slot name="trigger" @slotchange=${this._setTriggerAria}></slot>
</div>
<mwc-menu
.corner=${this.corner}
.menuCorner=${this.menuCorner}
.fixed=${this.fixed}
.multi=${this.multi}
.activatable=${this.activatable}
.y=${this.y}
.x=${this.x}
>
<slot></slot>
</mwc-menu>
`}},{kind:"method",key:"firstUpdated",value:function(e){l(a(s.prototype),"firstUpdated",this).call(this,e),"rtl"===document.dir&&this.updateComplete.then((()=>{this.querySelectorAll("mwc-list-item").forEach((e=>{const t=document.createElement("style");t.innerHTML="span.material-icons:first-of-type { margin-left: var(--mdc-list-item-graphic-margin, 32px) !important; margin-right: 0px !important;}",e.shadowRoot.appendChild(t)}))}))}},{kind:"method",key:"_handleClick",value:function(){this.disabled||(this._menu.anchor=this,this._menu.show())}},{kind:"get",key:"_triggerButton",value:function(){return this.querySelector('ha-icon-button[slot="trigger"], mwc-button[slot="trigger"]')}},{kind:"method",key:"_setTriggerAria",value:function(){this._triggerButton&&(this._triggerButton.ariaHasPopup="menu")}},{kind:"get",static:!0,key:"styles",value:function(){return d`
:host {
display: inline-block;
position: relative;
}
::slotted([disabled]) {
color: var(--disabled-text-color);
}
`}}]}}),t);
@@ -1,390 +0,0 @@
import{a as e,h as t,e as i,g as a,t as s,$ as o,j as r,R as n,w as l,r as h,n as c,m as d,L as p,N as u,o as v,b as f,aI as b,ai as m,c as k,E as g,aJ as y,aC as w,aK as x,aL as $,d as _,s as R}from"./main-ad130be7.js";import{f as z}from"./c.3243a8b0.js";import{c as j}from"./c.4a97632a.js";import"./c.f1291e50.js";import"./c.2d5ed670.js";import"./c.97b7c4b0.js";import{r as F}from"./c.4204ca09.js";import{i as P}from"./c.21c042d4.js";import{s as I}from"./c.2645c235.js";import"./c.a5f69ed4.js";import"./c.3f859915.js";import"./c.9b92f489.js";import"./c.82eccc94.js";import"./c.8e28b461.js";import"./c.4feb0cb8.js";import"./c.0ca5587f.js";import"./c.5d3ce9d6.js";import"./c.f6611997.js";import"./c.743a15a1.js";import"./c.4266acdb.js";e([c("ha-tab")],(function(e,t){return{F:class extends t{constructor(...t){super(...t),e(this)}},d:[{kind:"field",decorators:[i({type:Boolean,reflect:!0})],key:"active",value:()=>!1},{kind:"field",decorators:[i({type:Boolean,reflect:!0})],key:"narrow",value:()=>!1},{kind:"field",decorators:[i()],key:"name",value:void 0},{kind:"field",decorators:[a("mwc-ripple")],key:"_ripple",value:void 0},{kind:"field",decorators:[s()],key:"_shouldRenderRipple",value:()=>!1},{kind:"method",key:"render",value:function(){return o`
<div
tabindex="0"
role="tab"
aria-selected=${this.active}
aria-label=${r(this.name)}
@focus=${this.handleRippleFocus}
@blur=${this.handleRippleBlur}
@mousedown=${this.handleRippleActivate}
@mouseup=${this.handleRippleDeactivate}
@mouseenter=${this.handleRippleMouseEnter}
@mouseleave=${this.handleRippleMouseLeave}
@touchstart=${this.handleRippleActivate}
@touchend=${this.handleRippleDeactivate}
@touchcancel=${this.handleRippleDeactivate}
@keydown=${this._handleKeyDown}
>
${this.narrow?o`<slot name="icon"></slot>`:""}
<span class="name">${this.name}</span>
${this._shouldRenderRipple?o`<mwc-ripple></mwc-ripple>`:""}
</div>
`}},{kind:"field",key:"_rippleHandlers",value(){return new n((()=>(this._shouldRenderRipple=!0,this._ripple)))}},{kind:"method",key:"_handleKeyDown",value:function(e){13===e.keyCode&&e.target.click()}},{kind:"method",decorators:[l({passive:!0})],key:"handleRippleActivate",value:function(e){this._rippleHandlers.startPress(e)}},{kind:"method",key:"handleRippleDeactivate",value:function(){this._rippleHandlers.endPress()}},{kind:"method",key:"handleRippleMouseEnter",value:function(){this._rippleHandlers.startHover()}},{kind:"method",key:"handleRippleMouseLeave",value:function(){this._rippleHandlers.endHover()}},{kind:"method",key:"handleRippleFocus",value:function(){this._rippleHandlers.startFocus()}},{kind:"method",key:"handleRippleBlur",value:function(){this._rippleHandlers.endFocus()}},{kind:"get",static:!0,key:"styles",value:function(){return h`
div {
padding: 0 32px;
display: flex;
flex-direction: column;
text-align: center;
box-sizing: border-box;
align-items: center;
justify-content: center;
width: 100%;
height: var(--header-height);
cursor: pointer;
position: relative;
outline: none;
}
.name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
:host([active]) {
color: var(--primary-color);
}
:host(:not([narrow])[active]) div {
border-bottom: 2px solid var(--primary-color);
}
:host([narrow]) {
min-width: 0;
display: flex;
justify-content: center;
overflow: hidden;
}
:host([narrow]) div {
padding: 0 4px;
}
`}}]}}),t),e([c("hass-tabs-subpage")],(function(e,t){class a extends t{constructor(...t){super(...t),e(this)}}return{F:a,d:[{kind:"field",decorators:[i({attribute:!1})],key:"hass",value:void 0},{kind:"field",decorators:[i({type:Boolean})],key:"supervisor",value:()=>!1},{kind:"field",decorators:[i({attribute:!1})],key:"localizeFunc",value:void 0},{kind:"field",decorators:[i({type:String,attribute:"back-path"})],key:"backPath",value:void 0},{kind:"field",decorators:[i()],key:"backCallback",value:void 0},{kind:"field",decorators:[i({type:Boolean,attribute:"main-page"})],key:"mainPage",value:()=>!1},{kind:"field",decorators:[i({attribute:!1})],key:"route",value:void 0},{kind:"field",decorators:[i({attribute:!1})],key:"tabs",value:void 0},{kind:"field",decorators:[i({type:Boolean,reflect:!0})],key:"narrow",value:()=>!1},{kind:"field",decorators:[i({type:Boolean,reflect:!0,attribute:"is-wide"})],key:"isWide",value:()=>!1},{kind:"field",decorators:[i({type:Boolean,reflect:!0})],key:"rtl",value:()=>!1},{kind:"field",decorators:[s()],key:"_activeTab",value:void 0},{kind:"field",decorators:[F(".content")],key:"_savedScrollPos",value:void 0},{kind:"field",key:"_getTabs",value(){return d(((e,t,i,a,s,r,n)=>{const l=e.filter((e=>(!e.component||e.core||P(this.hass,e.component))&&(!e.advancedOnly||i)));if(l.length<2){if(1===l.length){const e=l[0];return[e.translationKey?n(e.translationKey):e.name]}return[""]}return l.map((e=>o`
<a href=${e.path}>
<ha-tab
.hass=${this.hass}
.active=${e.path===(null==t?void 0:t.path)}
.narrow=${this.narrow}
.name=${e.translationKey?n(e.translationKey):e.name}
>
${e.iconPath?o`<ha-svg-icon
slot="icon"
.path=${e.iconPath}
></ha-svg-icon>`:""}
</ha-tab>
</a>
`))}))}},{kind:"method",key:"willUpdate",value:function(e){if(e.has("route")&&(this._activeTab=this.tabs.find((e=>`${this.route.prefix}${this.route.path}`.includes(e.path)))),e.has("hass")){const t=e.get("hass");t&&t.language===this.hass.language||(this.rtl=j(this.hass))}p(u(a.prototype),"willUpdate",this).call(this,e)}},{kind:"method",key:"render",value:function(){var e,t;const i=this._getTabs(this.tabs,this._activeTab,null===(e=this.hass.userData)||void 0===e?void 0:e.showAdvanced,this.hass.config.components,this.hass.language,this.narrow,this.localizeFunc||this.hass.localize),a=i.length>1;return o`
<div class="toolbar">
${this.mainPage||!this.backPath&&null!==(t=history.state)&&void 0!==t&&t.root?o`
<ha-menu-button
.hassio=${this.supervisor}
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
`:this.backPath?o`
<a href=${this.backPath}>
<ha-icon-button-arrow-prev
.hass=${this.hass}
></ha-icon-button-arrow-prev>
</a>
`:o`
<ha-icon-button-arrow-prev
.hass=${this.hass}
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`}
${this.narrow||!a?o`<div class="main-title">
<slot name="header">${a?"":i[0]}</slot>
</div>`:""}
${a?o`
<div id="tabbar" class=${v({"bottom-bar":this.narrow})}>
${i}
</div>
`:""}
<div id="toolbar-icon">
<slot name="toolbar-icon"></slot>
</div>
</div>
<div
class="content ${v({tabs:a})}"
@scroll=${this._saveScrollPos}
>
<slot></slot>
</div>
<div id="fab" class=${v({tabs:a})}>
<slot name="fab"></slot>
</div>
`}},{kind:"method",decorators:[l({passive:!0})],key:"_saveScrollPos",value:function(e){this._savedScrollPos=e.target.scrollTop}},{kind:"method",key:"_backTapped",value:function(){this.backCallback?this.backCallback():history.back()}},{kind:"get",static:!0,key:"styles",value:function(){return h`
:host {
display: block;
height: 100%;
background-color: var(--primary-background-color);
}
:host([narrow]) {
width: 100%;
position: fixed;
}
ha-menu-button {
margin-right: 24px;
}
.toolbar {
display: flex;
align-items: center;
font-size: 20px;
height: var(--header-height);
background-color: var(--sidebar-background-color);
font-weight: 400;
border-bottom: 1px solid var(--divider-color);
padding: 0 16px;
box-sizing: border-box;
}
.toolbar a {
color: var(--sidebar-text-color);
text-decoration: none;
}
.bottom-bar a {
width: 25%;
}
#tabbar {
display: flex;
font-size: 14px;
overflow: hidden;
}
#tabbar > a {
overflow: hidden;
max-width: 45%;
}
#tabbar.bottom-bar {
position: absolute;
bottom: 0;
left: 0;
padding: 0 16px;
box-sizing: border-box;
background-color: var(--sidebar-background-color);
border-top: 1px solid var(--divider-color);
justify-content: space-around;
z-index: 2;
font-size: 12px;
width: 100%;
padding-bottom: env(safe-area-inset-bottom);
}
#tabbar:not(.bottom-bar) {
flex: 1;
justify-content: center;
}
:host(:not([narrow])) #toolbar-icon {
min-width: 40px;
}
ha-menu-button,
ha-icon-button-arrow-prev,
::slotted([slot="toolbar-icon"]) {
display: flex;
flex-shrink: 0;
pointer-events: auto;
color: var(--sidebar-icon-color);
}
.main-title {
flex: 1;
max-height: var(--header-height);
line-height: 20px;
color: var(--sidebar-text-color);
margin: var(--main-title-margin, 0 0 0 24px);
}
.content {
position: relative;
width: calc(
100% - env(safe-area-inset-left) - env(safe-area-inset-right)
);
margin-left: env(safe-area-inset-left);
margin-right: env(safe-area-inset-right);
height: calc(100% - 1px - var(--header-height));
height: calc(
100% - 1px - var(--header-height) - env(safe-area-inset-bottom)
);
overflow: auto;
-webkit-overflow-scrolling: touch;
}
:host([narrow]) .content.tabs {
height: calc(100% - 2 * var(--header-height));
height: calc(
100% - 2 * var(--header-height) - env(safe-area-inset-bottom)
);
}
#fab {
position: fixed;
right: calc(16px + env(safe-area-inset-right));
bottom: calc(16px + env(safe-area-inset-bottom));
z-index: 1;
}
:host([narrow]) #fab.tabs {
bottom: calc(84px + env(safe-area-inset-bottom));
}
#fab[is-wide] {
bottom: 24px;
right: 24px;
}
:host([rtl]) #fab {
right: auto;
left: calc(16px + env(safe-area-inset-left));
}
:host([rtl][is-wide]) #fab {
bottom: 24px;
left: 24px;
right: auto;
}
`}}]}}),t);let E=e([c("hacs-store-panel")],(function(e,t){return{F:class extends t{constructor(...t){super(...t),e(this)}},d:[{kind:"field",decorators:[i({attribute:!1})],key:"filters",value:()=>({})},{kind:"field",decorators:[i({attribute:!1})],key:"hacs",value:void 0},{kind:"field",decorators:[i()],key:"_searchInput",value:()=>""},{kind:"field",decorators:[i({attribute:!1})],key:"hass",value:void 0},{kind:"field",decorators:[i({attribute:!1})],key:"narrow",value:void 0},{kind:"field",decorators:[i({attribute:!1})],key:"isWide",value:void 0},{kind:"field",decorators:[i({attribute:!1})],key:"route",value:void 0},{kind:"field",decorators:[i({attribute:!1})],key:"sections",value:void 0},{kind:"field",decorators:[i()],key:"section",value:void 0},{kind:"field",key:"_repositoriesInActiveSection",value(){return d(((e,t)=>[(null==e?void 0:e.filter((e=>{var i,a,s;return(null===(i=this.hacs.sections)||void 0===i||null===(a=i.find((e=>e.id===t)))||void 0===a||null===(s=a.categories)||void 0===s?void 0:s.includes(e.category))&&e.installed})))||[],(null==e?void 0:e.filter((e=>{var i,a,s;return(null===(i=this.hacs.sections)||void 0===i||null===(a=i.find((e=>e.id===t)))||void 0===a||null===(s=a.categories)||void 0===s?void 0:s.includes(e.category))&&e.new&&!e.installed})))||[]]))}},{kind:"get",key:"allRepositories",value:function(){const[e,t]=this._repositoriesInActiveSection(this.hacs.repositories,this.section);return t.concat(e)}},{kind:"field",key:"_filterRepositories",value:()=>d(z)},{kind:"get",key:"visibleRepositories",value:function(){const e=this.allRepositories.filter((e=>{var t,i;return null===(t=this.filters[this.section])||void 0===t||null===(i=t.find((t=>t.id===e.category)))||void 0===i?void 0:i.checked}));return this._filterRepositories(e,this._searchInput)}},{kind:"method",key:"firstUpdated",value:async function(){this.addEventListener("filter-change",(e=>this._updateFilters(e)))}},{kind:"method",key:"_updateFilters",value:function(e){var t;const i=null===(t=this.filters[this.section])||void 0===t?void 0:t.find((t=>t.id===e.detail.id));this.filters[this.section].find((e=>e.id===i.id)).checked=!i.checked,this.requestUpdate()}},{kind:"method",key:"render",value:function(){var e;if(!this.hacs)return o``;const t=this._repositoriesInActiveSection(this.hacs.repositories,this.section)[1];if(!this.filters[this.section]&&this.hacs.info.categories){var i;const e=null===(i=f(this.hacs.language,this.route))||void 0===i?void 0:i.categories;this.filters[this.section]=[],null==e||e.filter((e=>{var t;return null===(t=this.hacs.info)||void 0===t?void 0:t.categories.includes(e)})).forEach((e=>{this.filters[this.section].push({id:e,value:e,checked:!0})}))}return o`<hass-tabs-subpage
back-path="/hacs/entry"
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
.tabs=${this.hacs.sections}
hasFab
>
<ha-icon-overflow-menu
slot="toolbar-icon"
narrow
.hass=${this.hass}
.items=${[{path:b,label:this.hacs.localize("menu.documentation"),action:()=>m.open("https://hacs.xyz/","_blank","noreferrer=true")},{path:k,label:"GitHub",action:()=>m.open("https://github.com/hacs","_blank","noreferrer=true")},{path:g,label:this.hacs.localize("menu.open_issue"),action:()=>m.open("https://hacs.xyz/docs/issues","_blank","noreferrer=true")},{path:y,label:this.hacs.localize("menu.custom_repositories"),disabled:this.hacs.info.disabled_reason,action:()=>this.dispatchEvent(new CustomEvent("hacs-dialog",{detail:{type:"custom-repositories",repositories:this.hacs.repositories},bubbles:!0,composed:!0}))},{path:w,label:this.hacs.localize("menu.about"),action:()=>I(this,this.hacs)}]}
>
</ha-icon-overflow-menu>
${this.narrow?o`
<search-input
.hass=${this.hass}
class="header"
slot="header"
.label=${this.hacs.localize("search.downloaded")}
.filter=${this._searchInput||""}
@value-changed=${this._inputValueChanged}
></search-input>
`:o`<div class="search">
<search-input
.hass=${this.hass}
.label=${0===t.length?this.hacs.localize("search.downloaded"):this.hacs.localize("search.downloaded_new")}
.filter=${this._searchInput||""}
@value-changed=${this._inputValueChanged}
></search-input>
</div>`}
<div class="content ${this.narrow?"narrow-content":""}">
${(null===(e=this.filters[this.section])||void 0===e?void 0:e.length)>1?o`<div class="filters">
<hacs-filter
.hacs=${this.hacs}
.filters="${this.filters[this.section]}"
></hacs-filter>
</div>`:""}
${null!=t&&t.length?o`<ha-alert .rtl=${j(this.hass)}>
${this.hacs.localize("store.new_repositories_note")}
<mwc-button
class="max-content"
slot="action"
.label=${this.hacs.localize("menu.dismiss")}
@click=${this._clearAllNewRepositories}
>
</mwc-button>
</ha-alert> `:""}
<div class="container ${this.narrow?"narrow":""}">
${void 0===this.hacs.repositories?"":0===this.allRepositories.length?this._renderEmpty():0===this.visibleRepositories.length?this._renderNoResultsFound():this._renderRepositories()}
</div>
</div>
<ha-fab
slot="fab"
.label=${this.hacs.localize("store.explore")}
.extended=${!this.narrow}
@click=${this._addRepository}
>
<ha-svg-icon slot="icon" .path=${x}></ha-svg-icon>
</ha-fab>
</hass-tabs-subpage>`}},{kind:"method",key:"_renderRepositories",value:function(){return this.visibleRepositories.map((e=>o`<hacs-repository-card
.hass=${this.hass}
.hacs=${this.hacs}
.repository=${e}
.narrow=${this.narrow}
?narrow=${this.narrow}
></hacs-repository-card>`))}},{kind:"method",key:"_clearAllNewRepositories",value:async function(){var e;await $(this.hass,{categories:(null===(e=f(this.hacs.language,this.route))||void 0===e?void 0:e.categories)||[]})}},{kind:"method",key:"_renderNoResultsFound",value:function(){return o`<ha-alert
.rtl=${j(this.hass)}
alert-type="warning"
.title="${this.hacs.localize("store.no_repositories")} 😕"
>
${this.hacs.localize("store.no_repositories_found_desc1",{searchInput:this._searchInput})}
<br />
${this.hacs.localize("store.no_repositories_found_desc2")}
</ha-alert>`}},{kind:"method",key:"_renderEmpty",value:function(){return o`<ha-alert
.title="${this.hacs.localize("store.no_repositories")} 😕"
.rtl=${j(this.hass)}
>
${this.hacs.localize("store.no_repositories_desc1")}
<br />
${this.hacs.localize("store.no_repositories_desc2")}
</ha-alert>`}},{kind:"method",key:"_inputValueChanged",value:function(e){this._searchInput=e.detail.value,window.localStorage.setItem("hacs-search",this._searchInput)}},{kind:"method",key:"_addRepository",value:function(){this.dispatchEvent(new CustomEvent("hacs-dialog",{detail:{type:"add-repository",repositories:this.hacs.repositories,section:this.section},bubbles:!0,composed:!0}))}},{kind:"get",static:!0,key:"styles",value:function(){return[_,R,h`
.filter {
border-bottom: 1px solid var(--divider-color);
}
.content {
height: calc(100vh - 128px);
overflow: auto;
}
.narrow-content {
height: calc(100vh - 128px);
}
.container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(480px, 1fr));
justify-items: center;
grid-gap: 8px 8px;
padding: 8px 16px 16px;
margin-bottom: 64px;
}
ha-svg-icon {
color: var(--hcv-text-color-on-background);
}
hacs-repository-card {
max-width: 500px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
hacs-repository-card[narrow] {
width: 100%;
}
hacs-repository-card[narrow]:last-of-type {
margin-bottom: 64px;
}
ha-alert {
color: var(--hcv-text-color-primary);
display: block;
margin-top: -4px;
}
.narrow {
width: 100%;
display: block;
padding: 0px;
margin: 0;
}
search-input {
display: block;
}
search-input.header {
padding: 0;
}
.bottom-bar {
position: fixed !important;
}
.max-content {
width: max-content;
}
`]}}]}}),t);export{E as HacsStorePanel};
File diff suppressed because one or more lines are too long
@@ -1,16 +0,0 @@
import{a as e,e as t,i,L as a,N as d,$ as r,r as n,n as o}from"./main-ad130be7.js";import{H as s}from"./c.0a1cf8d0.js";e([o("ha-clickable-list-item")],(function(e,o){class s extends o{constructor(...t){super(...t),e(this)}}return{F:s,d:[{kind:"field",decorators:[t()],key:"href",value:void 0},{kind:"field",decorators:[t({type:Boolean})],key:"disableHref",value:()=>!1},{kind:"field",decorators:[t({type:Boolean,reflect:!0})],key:"openNewTab",value:()=>!1},{kind:"field",decorators:[i("a")],key:"_anchor",value:void 0},{kind:"method",key:"render",value:function(){const e=a(d(s.prototype),"render",this).call(this),t=this.href||"";return r`${this.disableHref?r`<a aria-role="option">${e}</a>`:r`<a
aria-role="option"
target=${this.openNewTab?"_blank":""}
href=${t}
>${e}</a
>`}`}},{kind:"method",key:"firstUpdated",value:function(){a(d(s.prototype),"firstUpdated",this).call(this),this.addEventListener("keydown",(e=>{"Enter"!==e.key&&" "!==e.key||this._anchor.click()}))}},{kind:"get",static:!0,key:"styles",value:function(){return[a(d(s),"styles",this),n`
a {
width: 100%;
height: 100%;
display: flex;
align-items: center;
padding-left: var(--mdc-list-side-padding, 20px);
padding-right: var(--mdc-list-side-padding, 20px);
overflow: hidden;
}
`]}}]}}),s);
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
const n=(n,o)=>n&&n.config.components.includes(o);export{n as i};

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