Merge branch 'dev' into aranet-threshold-level

This commit is contained in:
Parker Brown 2025-02-16 01:08:42 -07:00 committed by GitHub
commit a5a163a23f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
167 changed files with 5832 additions and 991 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

@ -134,14 +134,12 @@ DATA_REGISTRIES_LOADED: HassKey[None] = HassKey("bootstrap_registries_loaded")
LOG_SLOW_STARTUP_INTERVAL = 60
SLOW_STARTUP_CHECK_INTERVAL = 1
STAGE_0_SUBSTAGE_TIMEOUT = 60
STAGE_1_TIMEOUT = 120
STAGE_2_TIMEOUT = 300
WRAP_UP_TIMEOUT = 300
COOLDOWN_TIME = 60
DEBUGGER_INTEGRATIONS = {"debugpy"}
# Core integrations are unconditionally loaded
CORE_INTEGRATIONS = {"homeassistant", "persistent_notification"}
@ -152,6 +150,10 @@ LOGGING_AND_HTTP_DEPS_INTEGRATIONS = {
"isal",
# Set log levels
"logger",
# Ensure network config is available
# before hassio or any other integration is
# loaded that might create an aiohttp client session
"network",
# Error logging
"system_log",
"sentry",
@ -172,12 +174,27 @@ FRONTEND_INTEGRATIONS = {
# add it here.
"backup",
}
RECORDER_INTEGRATIONS = {
# Setup after frontend
# To record data
"recorder",
}
DISCOVERY_INTEGRATIONS = ("bluetooth", "dhcp", "ssdp", "usb", "zeroconf")
# Stage 0 is divided into substages. Each substage has a name, a set of integrations and a timeout.
# The substage containing recorder should have no timeout, as it could cancel a database migration.
# Recorder freezes "recorder" timeout during a migration, but it does not freeze other timeouts.
# The substages preceding it should also have no timeout, until we ensure that the recorder
# is not accidentally promoted as a dependency of any of the integrations in them.
# If we add timeouts to the frontend substages, we should make sure they don't apply in recovery mode.
STAGE_0_INTEGRATIONS = (
# Load logging and http deps as soon as possible
("logging, http deps", LOGGING_AND_HTTP_DEPS_INTEGRATIONS, None),
# Setup frontend
("frontend", FRONTEND_INTEGRATIONS, None),
# Setup recorder
("recorder", {"recorder"}, None),
# Start up debuggers. Start these first in case they want to wait.
("debugger", {"debugpy"}, STAGE_0_SUBSTAGE_TIMEOUT),
# Zeroconf is used for mdns resolution in aiohttp client helper.
("zeroconf", {"zeroconf"}, STAGE_0_SUBSTAGE_TIMEOUT),
)
DISCOVERY_INTEGRATIONS = ("bluetooth", "dhcp", "ssdp", "usb")
# Stage 1 integrations are not to be preimported in bootstrap.
STAGE_1_INTEGRATIONS = {
# We need to make sure discovery integrations
# update their deps before stage 2 integrations
@ -189,9 +206,8 @@ STAGE_1_INTEGRATIONS = {
"mqtt_eventstream",
# To provide account link implementations
"cloud",
# Ensure supervisor is available
"hassio",
}
DEFAULT_INTEGRATIONS = {
# These integrations are set up unless recovery mode is activated.
#
@ -232,22 +248,12 @@ DEFAULT_INTEGRATIONS_SUPERVISOR = {
# These integrations are set up if using the Supervisor
"hassio",
}
CRITICAL_INTEGRATIONS = {
# Recovery mode is activated if these integrations fail to set up
"frontend",
}
SETUP_ORDER = (
# Load logging and http deps as soon as possible
("logging, http deps", LOGGING_AND_HTTP_DEPS_INTEGRATIONS),
# Setup frontend
("frontend", FRONTEND_INTEGRATIONS),
# Setup recorder
("recorder", RECORDER_INTEGRATIONS),
# Start up debuggers. Start these first in case they want to wait.
("debugger", DEBUGGER_INTEGRATIONS),
)
#
# Storage keys we are likely to load during startup
# in order of when we expect to load them.
@ -694,7 +700,6 @@ async def async_mount_local_lib_path(config_dir: str) -> str:
return deps_dir
@core.callback
def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]:
"""Get domains of components to set up."""
# Filter out the repeating and common config section [homeassistant]
@ -890,69 +895,48 @@ async def _async_set_up_integrations(
domains_to_setup, integration_cache = await _async_resolve_domains_to_setup(
hass, config
)
stage_2_domains = domains_to_setup.copy()
# Initialize recorder
if "recorder" in domains_to_setup:
recorder.async_initialize_recorder(hass)
pre_stage_domains = [
(name, domains_to_setup & domain_group) for name, domain_group in SETUP_ORDER
stage_0_and_1_domains: list[tuple[str, set[str], int | None]] = [
*(
(name, domain_group & domains_to_setup, timeout)
for name, domain_group, timeout in STAGE_0_INTEGRATIONS
),
("stage 1", STAGE_1_INTEGRATIONS & domains_to_setup, STAGE_1_TIMEOUT),
]
# calculate what components to setup in what stage
stage_1_domains: set[str] = set()
_LOGGER.info("Setting up stage 0 and 1")
for name, domain_group, timeout in stage_0_and_1_domains:
if not domain_group:
continue
# Find all dependencies of any dependency of any stage 1 integration that
# we plan on loading and promote them to stage 1. This is done only to not
# get misleading log messages
deps_promotion: set[str] = STAGE_1_INTEGRATIONS
while deps_promotion:
old_deps_promotion = deps_promotion
deps_promotion = set()
_LOGGER.info("Setting up %s: %s", name, domain_group)
to_be_loaded = domain_group.copy()
to_be_loaded.update(
dep
for domain in domain_group
if (integration := integration_cache.get(domain)) is not None
for dep in integration.all_dependencies
)
async_set_domains_to_be_loaded(hass, to_be_loaded)
stage_2_domains -= to_be_loaded
for domain in old_deps_promotion:
if domain not in domains_to_setup or domain in stage_1_domains:
continue
stage_1_domains.add(domain)
if (dep_itg := integration_cache.get(domain)) is None:
continue
deps_promotion.update(dep_itg.all_dependencies)
stage_2_domains = domains_to_setup - stage_1_domains
for name, domain_group in pre_stage_domains:
if domain_group:
stage_2_domains -= domain_group
_LOGGER.info("Setting up %s: %s", name, domain_group)
to_be_loaded = domain_group.copy()
to_be_loaded.update(
dep
for domain in domain_group
if (integration := integration_cache.get(domain)) is not None
for dep in integration.all_dependencies
)
async_set_domains_to_be_loaded(hass, to_be_loaded)
if timeout is None:
await _async_setup_multi_components(hass, domain_group, config)
# Enables after dependencies when setting up stage 1 domains
async_set_domains_to_be_loaded(hass, stage_1_domains)
# Start setup
if stage_1_domains:
_LOGGER.info("Setting up stage 1: %s", stage_1_domains)
try:
async with hass.timeout.async_timeout(
STAGE_1_TIMEOUT, cool_down=COOLDOWN_TIME
):
await _async_setup_multi_components(hass, stage_1_domains, config)
except TimeoutError:
_LOGGER.warning(
"Setup timed out for stage 1 waiting on %s - moving forward",
hass._active_tasks, # noqa: SLF001
)
else:
try:
async with hass.timeout.async_timeout(timeout, cool_down=COOLDOWN_TIME):
await _async_setup_multi_components(hass, domain_group, config)
except TimeoutError:
_LOGGER.warning(
"Setup timed out for %s waiting on %s - moving forward",
name,
hass._active_tasks, # noqa: SLF001
)
# Add after dependencies when setting up stage 2 domains
async_set_domains_to_be_loaded(hass, stage_2_domains)

View File

@ -90,7 +90,7 @@
},
"alarm_arm_home": {
"name": "Arm home",
"description": "Sets the alarm to: _armed, but someone is home_.",
"description": "Arms the alarm in the home mode.",
"fields": {
"code": {
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]",
@ -100,7 +100,7 @@
},
"alarm_arm_away": {
"name": "Arm away",
"description": "Sets the alarm to: _armed, no one home_.",
"description": "Arms the alarm in the away mode.",
"fields": {
"code": {
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]",
@ -110,7 +110,7 @@
},
"alarm_arm_night": {
"name": "Arm night",
"description": "Sets the alarm to: _armed for the night_.",
"description": "Arms the alarm in the night mode.",
"fields": {
"code": {
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]",
@ -120,7 +120,7 @@
},
"alarm_arm_vacation": {
"name": "Arm vacation",
"description": "Sets the alarm to: _armed for vacation_.",
"description": "Arms the alarm in the vacation mode.",
"fields": {
"code": {
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]",
@ -130,7 +130,7 @@
},
"alarm_trigger": {
"name": "Trigger",
"description": "Trigger the alarm manually.",
"description": "Triggers the alarm manually.",
"fields": {
"code": {
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]",

View File

@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/arcam_fmj",
"iot_class": "local_polling",
"loggers": ["arcam"],
"requirements": ["arcam-fmj==1.5.2"],
"requirements": ["arcam-fmj==1.8.0"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",

View File

@ -19,6 +19,7 @@ from .const import (
DOMAIN,
AssistSatelliteEntityFeature,
)
from .entity import AssistSatelliteConfiguration
CONNECTION_TEST_TIMEOUT = 30
@ -91,7 +92,16 @@ def websocket_get_configuration(
)
return
config_dict = asdict(satellite.async_get_configuration())
try:
config_dict = asdict(satellite.async_get_configuration())
except NotImplementedError:
# Stub configuration
config_dict = asdict(
AssistSatelliteConfiguration(
available_wake_words=[], active_wake_words=[], max_active_wake_words=1
)
)
config_dict["pipeline_entity_id"] = satellite.pipeline_entity_id
config_dict["vad_entity_id"] = satellite.vad_sensitivity_entity_id

View File

@ -24,6 +24,8 @@ PLATFORMS = [
Platform.FAN,
Platform.LIGHT,
Platform.SELECT,
Platform.SWITCH,
Platform.TIME,
]

View File

@ -14,5 +14,5 @@
"documentation": "https://www.home-assistant.io/integrations/balboa",
"iot_class": "local_push",
"loggers": ["pybalboa"],
"requirements": ["pybalboa==1.1.2"]
"requirements": ["pybalboa==1.1.3"]
}

View File

@ -78,6 +78,19 @@
"high": "High"
}
}
},
"switch": {
"filter_cycle_2_enabled": {
"name": "Filter cycle 2 enabled"
}
},
"time": {
"filter_cycle_start": {
"name": "Filter cycle {index} start"
},
"filter_cycle_end": {
"name": "Filter cycle {index} end"
}
}
}
}

View File

@ -0,0 +1,48 @@
"""Support for Balboa switches."""
from __future__ import annotations
from typing import Any
from pybalboa import SpaClient
from homeassistant.components.switch import SwitchEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BalboaConfigEntry
from .entity import BalboaEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: BalboaConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the spa's switches."""
spa = entry.runtime_data
async_add_entities([BalboaSwitchEntity(spa)])
class BalboaSwitchEntity(BalboaEntity, SwitchEntity):
"""Representation of a Balboa switch entity."""
def __init__(self, spa: SpaClient) -> None:
"""Initialize a Balboa switch entity."""
super().__init__(spa, "filter_cycle_2_enabled")
self._attr_entity_category = EntityCategory.CONFIG
self._attr_translation_key = "filter_cycle_2_enabled"
@property
def is_on(self) -> bool:
"""Return True if entity is on."""
return self._client.filter_cycle_2_enabled
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
await self._client.configure_filter_cycle(2, enabled=True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
await self._client.configure_filter_cycle(2, enabled=False)

View File

@ -0,0 +1,56 @@
"""Support for Balboa times."""
from __future__ import annotations
from datetime import time
import itertools
from typing import Any
from pybalboa import SpaClient
from homeassistant.components.time import TimeEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BalboaConfigEntry
from .entity import BalboaEntity
FILTER_CYCLE = "filter_cycle_"
async def async_setup_entry(
hass: HomeAssistant,
entry: BalboaConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the spa's times."""
spa = entry.runtime_data
async_add_entities(
BalboaTimeEntity(spa, index, period)
for index, period in itertools.product((1, 2), ("start", "end"))
)
class BalboaTimeEntity(BalboaEntity, TimeEntity):
"""Representation of a Balboa time entity."""
entity_category = EntityCategory.CONFIG
def __init__(self, spa: SpaClient, index: int, period: str) -> None:
"""Initialize a Balboa time entity."""
super().__init__(spa, f"{FILTER_CYCLE}{index}_{period}")
self.index = index
self.period = period
self._attr_translation_key = f"{FILTER_CYCLE}{period}"
self._attr_translation_placeholders = {"index": str(index)}
@property
def native_value(self) -> time | None:
"""Return the value reported by the time."""
return getattr(self._client, f"{FILTER_CYCLE}{self.index}_{self.period}")
async def async_set_value(self, value: time) -> None:
"""Change the time."""
args: dict[str, Any] = {self.period: value}
await self._client.configure_filter_cycle(self.index, **args)

View File

@ -4,12 +4,13 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.components.event import DOMAIN as EVENT_DOMAIN
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import BangOlufsenConfigEntry
from .const import DOMAIN
from .const import DEVICE_BUTTONS, DOMAIN
async def async_get_config_entry_diagnostics(
@ -25,8 +26,9 @@ async def async_get_config_entry_diagnostics(
if TYPE_CHECKING:
assert config_entry.unique_id
# Add media_player entity's state
entity_registry = er.async_get(hass)
# Add media_player entity's state
if entity_id := entity_registry.async_get_entity_id(
MEDIA_PLAYER_DOMAIN, DOMAIN, config_entry.unique_id
):
@ -37,4 +39,16 @@ async def async_get_config_entry_diagnostics(
state_dict.pop("context")
data["media_player"] = state_dict
# Add button Event entity states (if enabled)
for device_button in DEVICE_BUTTONS:
if entity_id := entity_registry.async_get_entity_id(
EVENT_DOMAIN, DOMAIN, f"{config_entry.unique_id}_{device_button}"
):
if state := hass.states.get(entity_id):
state_dict = dict(state.as_dict())
# Remove context as it is not relevant
state_dict.pop("context")
data[f"{device_button}_event"] = state_dict
return data

View File

@ -7,5 +7,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["bring_api"],
"quality_scale": "platinum",
"requirements": ["bring-api==1.0.2"]
}

View File

@ -10,9 +10,9 @@ rules:
config-flow: done
dependency-transparency: done
docs-actions: done
docs-high-level-description: todo
docs-installation-instructions: todo
docs-removal-instructions: todo
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: The integration registers no events
@ -26,8 +26,10 @@ rules:
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
docs-configuration-parameters:
status: exempt
comment: Integration has no configuration parameters
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable:
@ -46,13 +48,15 @@ rules:
discovery:
status: exempt
comment: Integration is a service and has no devices.
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices:
status: exempt
comment: Integration is a service and has no devices.
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: done
entity-category: done
entity-device-class: done

View File

@ -13,6 +13,6 @@
"integration_type": "system",
"iot_class": "cloud_push",
"loggers": ["acme", "hass_nabucasa", "snitun"],
"requirements": ["hass-nabucasa==0.90.0"],
"requirements": ["hass-nabucasa==0.92.0"],
"single_config_entry": true
}

View File

@ -53,6 +53,7 @@ from homeassistant.helpers import (
)
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_track_state_added_domain
from homeassistant.util import language as language_util
from homeassistant.util.json import JsonObjectType, json_loads_object
from .chat_log import AssistantContent, async_get_chat_log
@ -914,26 +915,20 @@ class DefaultAgent(ConversationEntity):
def _load_intents(self, language: str) -> LanguageIntents | None:
"""Load all intents for language (run inside executor)."""
intents_dict: dict[str, Any] = {}
language_variant: str | None = None
supported_langs = set(get_languages())
# Choose a language variant upfront and commit to it for custom
# sentences, etc.
all_language_variants = {lang.lower(): lang for lang in supported_langs}
lang_matches = language_util.matches(language, supported_langs)
# en-US, en_US, en, ...
for maybe_variant in _get_language_variations(language):
matching_variant = all_language_variants.get(maybe_variant.lower())
if matching_variant:
language_variant = matching_variant
break
if not language_variant:
if not lang_matches:
_LOGGER.warning(
"Unable to find supported language variant for %s", language
)
return None
language_variant = lang_matches[0]
# Load intents for this language variant
lang_variant_intents = get_intents(language_variant, json_load=json_load)

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/econet",
"iot_class": "cloud_push",
"loggers": ["paho_mqtt", "pyeconet"],
"requirements": ["pyeconet==0.1.27"]
"requirements": ["pyeconet==0.1.28"]
}

View File

@ -250,7 +250,7 @@
"message": "Params are required for the command: {command}"
},
"vacuum_raw_get_positions_not_supported": {
"message": "Getting the positions of the chargers and the device itself is not supported"
"message": "Retrieving the positions of the chargers and the device itself is not supported"
}
},
"selector": {
@ -264,7 +264,7 @@
"services": {
"raw_get_positions": {
"name": "Get raw positions",
"description": "Get the raw response for the positions of the chargers and the device itself."
"description": "Retrieves a raw response containing the positions of the chargers and the device itself."
}
}
}

View File

@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
from .entity import EnvoyBaseEntity
from .entity import EnvoyBaseEntity, exception_handler
PARALLEL_UPDATES = 1
@ -192,6 +192,7 @@ class EnvoyRelaySelectEntity(EnvoyBaseEntity, SelectEntity):
"""Return the state of the Enpower switch."""
return self.entity_description.value_fn(self.relay)
@exception_handler
async def async_select_option(self, option: str) -> None:
"""Update the relay."""
await self.entity_description.update_fn(self.envoy, self.relay, option)
@ -243,6 +244,7 @@ class EnvoyStorageSettingsSelectEntity(EnvoyBaseEntity, SelectEntity):
assert self.data.tariff.storage_settings is not None
return self.entity_description.value_fn(self.data.tariff.storage_settings)
@exception_handler
async def async_select_option(self, option: str) -> None:
"""Update the relay."""
await self.entity_description.update_fn(self.envoy, option)

View File

@ -16,7 +16,7 @@
"loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"],
"mqtt": ["esphome/discover/#"],
"requirements": [
"aioesphomeapi==29.0.0",
"aioesphomeapi==29.0.2",
"esphome-dashboard-api==1.2.3",
"bleak-esphome==2.7.1"
],

View File

@ -36,11 +36,11 @@
"issues": {
"import_failed_not_allowed_path": {
"title": "The Folder Watcher YAML configuration could not be imported",
"description": "Configuring Folder Watcher using YAML is being removed but your configuration could not be imported as the folder {path} is not in the configured allowlist.\n\nPlease add it to `{config_variable}` in config.yaml and restart Home Assistant to import it and fix this issue."
"description": "Configuring Folder Watcher using YAML is being removed but your configuration could not be imported as the folder {path} is not in the configured allowlist.\n\nPlease add it to `{config_variable}` in configuration.yaml and restart Home Assistant to import it and fix this issue."
},
"setup_not_allowed_path": {
"title": "The Folder Watcher configuration for {path} could not start",
"description": "The path {path} is not accessible or not allowed to be accessed.\n\nPlease check the path is accessible and add it to `{config_variable}` in config.yaml and restart Home Assistant to fix this issue."
"description": "The path {path} is not accessible or not allowed to be accessed.\n\nPlease check the path is accessible and add it to `{config_variable}` in configuration.yaml and restart Home Assistant to fix this issue."
}
},
"entity": {

View File

@ -21,5 +21,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20250210.0"]
"requirements": ["home-assistant-frontend==20250214.0"]
}

View File

@ -43,7 +43,7 @@ class HabiticaImage(HabiticaBase, ImageEntity):
translation_key=HabiticaImageEntity.AVATAR,
)
_attr_content_type = "image/png"
_current_appearance: Avatar | None = None
_avatar: Avatar | None = None
_cache: bytes | None = None
def __init__(
@ -55,13 +55,13 @@ class HabiticaImage(HabiticaBase, ImageEntity):
super().__init__(coordinator, self.entity_description)
ImageEntity.__init__(self, hass)
self._attr_image_last_updated = dt_util.utcnow()
self._avatar = extract_avatar(self.coordinator.data.user)
def _handle_coordinator_update(self) -> None:
"""Check if equipped gear and other things have changed since last avatar image generation."""
new_appearance = extract_avatar(self.coordinator.data.user)
if self._current_appearance != new_appearance:
self._current_appearance = new_appearance
if self._avatar != self.coordinator.data.user:
self._avatar = extract_avatar(self.coordinator.data.user)
self._attr_image_last_updated = dt_util.utcnow()
self._cache = None
@ -69,8 +69,6 @@ class HabiticaImage(HabiticaBase, ImageEntity):
async def async_image(self) -> bytes | None:
"""Return cached bytes, otherwise generate new avatar."""
if not self._cache and self._current_appearance:
self._cache = await self.coordinator.generate_avatar(
self._current_appearance
)
if not self._cache and self._avatar:
self._cache = await self.coordinator.generate_avatar(self._avatar)
return self._cache

View File

@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/habitica",
"iot_class": "cloud_polling",
"loggers": ["habiticalib"],
"quality_scale": "platinum",
"requirements": ["habiticalib==0.3.7"]
}

View File

@ -51,7 +51,7 @@ rules:
status: exempt
comment: No supportable devices.
docs-supported-functions: done
docs-troubleshooting: todo
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: exempt

View File

@ -9,5 +9,5 @@
},
"iot_class": "cloud_polling",
"loggers": ["apyhiveapi"],
"requirements": ["pyhive-integration==1.0.1"]
"requirements": ["pyhive-integration==1.0.2"]
}

View File

@ -2,11 +2,19 @@
from __future__ import annotations
from collections.abc import Awaitable
import logging
from typing import Any, cast
from aiohomeconnect.client import Client as HomeConnectClient
from aiohomeconnect.model import CommandKey, Option, OptionKey, ProgramKey, SettingKey
from aiohomeconnect.model import (
ArrayOfOptions,
CommandKey,
Option,
OptionKey,
ProgramKey,
SettingKey,
)
from aiohomeconnect.model.error import HomeConnectError
import voluptuous as vol
@ -19,34 +27,74 @@ from homeassistant.helpers import (
device_registry as dr,
)
from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from homeassistant.helpers.typing import ConfigType
from .api import AsyncConfigEntryAuth
from .const import (
AFFECTS_TO_ACTIVE_PROGRAM,
AFFECTS_TO_SELECTED_PROGRAM,
ATTR_AFFECTS_TO,
ATTR_KEY,
ATTR_PROGRAM,
ATTR_UNIT,
ATTR_VALUE,
DOMAIN,
OLD_NEW_UNIQUE_ID_SUFFIX_MAP,
PROGRAM_ENUM_OPTIONS,
SERVICE_OPTION_ACTIVE,
SERVICE_OPTION_SELECTED,
SERVICE_PAUSE_PROGRAM,
SERVICE_RESUME_PROGRAM,
SERVICE_SELECT_PROGRAM,
SERVICE_SET_PROGRAM_AND_OPTIONS,
SERVICE_SETTING,
SERVICE_START_PROGRAM,
SVE_TRANSLATION_PLACEHOLDER_KEY,
SVE_TRANSLATION_PLACEHOLDER_PROGRAM,
SVE_TRANSLATION_PLACEHOLDER_VALUE,
TRANSLATION_KEYS_PROGRAMS_MAP,
)
from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator
from .utils import get_dict_from_home_connect_error
from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PROGRAM_OPTIONS = {
bsh_key_to_translation_key(key): (
key,
value,
)
for key, value in {
OptionKey.BSH_COMMON_DURATION: int,
OptionKey.BSH_COMMON_START_IN_RELATIVE: int,
OptionKey.BSH_COMMON_FINISH_IN_RELATIVE: int,
OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY: int,
OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_MULTIPLE_BEVERAGES: bool,
OptionKey.DISHCARE_DISHWASHER_INTENSIV_ZONE: bool,
OptionKey.DISHCARE_DISHWASHER_BRILLIANCE_DRY: bool,
OptionKey.DISHCARE_DISHWASHER_VARIO_SPEED_PLUS: bool,
OptionKey.DISHCARE_DISHWASHER_SILENCE_ON_DEMAND: bool,
OptionKey.DISHCARE_DISHWASHER_HALF_LOAD: bool,
OptionKey.DISHCARE_DISHWASHER_EXTRA_DRY: bool,
OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS: bool,
OptionKey.DISHCARE_DISHWASHER_ECO_DRY: bool,
OptionKey.DISHCARE_DISHWASHER_ZEOLITE_DRY: bool,
OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE: int,
OptionKey.COOKING_OVEN_FAST_PRE_HEAT: bool,
OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE: bool,
OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE: bool,
}.items()
}
SERVICE_SETTING_SCHEMA = vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): str,
@ -58,6 +106,7 @@ SERVICE_SETTING_SCHEMA = vol.Schema(
}
)
# DEPRECATED: Remove in 2025.9.0
SERVICE_OPTION_SCHEMA = vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): str,
@ -70,6 +119,7 @@ SERVICE_OPTION_SCHEMA = vol.Schema(
}
)
# DEPRECATED: Remove in 2025.9.0
SERVICE_PROGRAM_SCHEMA = vol.Any(
{
vol.Required(ATTR_DEVICE_ID): str,
@ -93,6 +143,46 @@ SERVICE_PROGRAM_SCHEMA = vol.Any(
},
)
def _require_program_or_at_least_one_option(data: dict) -> dict:
if ATTR_PROGRAM not in data and not any(
option_key in data for option_key in (PROGRAM_ENUM_OPTIONS | PROGRAM_OPTIONS)
):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="required_program_or_one_option_at_least",
)
return data
SERVICE_PROGRAM_AND_OPTIONS_SCHEMA = vol.All(
vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): str,
vol.Required(ATTR_AFFECTS_TO): vol.In(
[AFFECTS_TO_ACTIVE_PROGRAM, AFFECTS_TO_SELECTED_PROGRAM]
),
vol.Optional(ATTR_PROGRAM): vol.In(TRANSLATION_KEYS_PROGRAMS_MAP.keys()),
}
)
.extend(
{
vol.Optional(translation_key): vol.In(allowed_values.keys())
for translation_key, (
key,
allowed_values,
) in PROGRAM_ENUM_OPTIONS.items()
}
)
.extend(
{
vol.Optional(translation_key): schema
for translation_key, (key, schema) in PROGRAM_OPTIONS.items()
}
),
_require_program_or_at_least_one_option,
)
SERVICE_COMMAND_SCHEMA = vol.Schema({vol.Required(ATTR_DEVICE_ID): str})
PLATFORMS = [
@ -144,7 +234,7 @@ async def _get_client_and_ha_id(
return entry.runtime_data.client, ha_id
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901
"""Set up Home Connect component."""
async def _async_service_program(call: ServiceCall, start: bool):
@ -165,6 +255,57 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
else None
)
async_create_issue(
hass,
DOMAIN,
"deprecated_set_program_and_option_actions",
breaks_in_ha_version="2025.9.0",
is_fixable=True,
is_persistent=True,
severity=IssueSeverity.WARNING,
translation_key="deprecated_set_program_and_option_actions",
translation_placeholders={
"new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS,
"remove_release": "2025.9.0",
"deprecated_action_yaml": "\n".join(
[
"```yaml",
f"action: {DOMAIN}.{SERVICE_START_PROGRAM if start else SERVICE_SELECT_PROGRAM}",
"data:",
f" {ATTR_DEVICE_ID}: DEVICE_ID",
f" {ATTR_PROGRAM}: {program}",
*([f" {ATTR_KEY}: {options[0].key}"] if options else []),
*([f" {ATTR_VALUE}: {options[0].value}"] if options else []),
*(
[f" {ATTR_UNIT}: {options[0].unit}"]
if options and options[0].unit
else []
),
"```",
]
),
"new_action_yaml": "\n ".join(
[
"```yaml",
f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}",
"data:",
f" {ATTR_DEVICE_ID}: DEVICE_ID",
f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if start else AFFECTS_TO_SELECTED_PROGRAM}",
f" {ATTR_PROGRAM}: {bsh_key_to_translation_key(program.value)}",
*(
[
f" {bsh_key_to_translation_key(options[0].key)}: {options[0].value}"
]
if options
else []
),
"```",
]
),
"repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)",
},
)
try:
if start:
await client.start_program(ha_id, program_key=program, options=options)
@ -189,6 +330,44 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
unit = call.data.get(ATTR_UNIT)
client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID])
async_create_issue(
hass,
DOMAIN,
"deprecated_set_program_and_option_actions",
breaks_in_ha_version="2025.9.0",
is_fixable=True,
is_persistent=True,
severity=IssueSeverity.WARNING,
translation_key="deprecated_set_program_and_option_actions",
translation_placeholders={
"new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS,
"remove_release": "2025.9.0",
"deprecated_action_yaml": "\n".join(
[
"```yaml",
f"action: {DOMAIN}.{SERVICE_OPTION_ACTIVE if active else SERVICE_OPTION_SELECTED}",
"data:",
f" {ATTR_DEVICE_ID}: DEVICE_ID",
f" {ATTR_KEY}: {option_key}",
f" {ATTR_VALUE}: {value}",
*([f" {ATTR_UNIT}: {unit}"] if unit else []),
"```",
]
),
"new_action_yaml": "\n ".join(
[
"```yaml",
f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}",
"data:",
f" {ATTR_DEVICE_ID}: DEVICE_ID",
f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if active else AFFECTS_TO_SELECTED_PROGRAM}",
f" {bsh_key_to_translation_key(option_key)}: {value}",
"```",
]
),
"repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)",
},
)
try:
if active:
await client.set_active_program_option(
@ -272,6 +451,76 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Service for selecting a program."""
await _async_service_program(call, False)
async def async_service_set_program_and_options(call: ServiceCall):
"""Service for setting a program and options."""
data = dict(call.data)
program = data.pop(ATTR_PROGRAM, None)
affects_to = data.pop(ATTR_AFFECTS_TO)
client, ha_id = await _get_client_and_ha_id(hass, data.pop(ATTR_DEVICE_ID))
options: list[Option] = []
for option, value in data.items():
if option in PROGRAM_ENUM_OPTIONS:
options.append(
Option(
PROGRAM_ENUM_OPTIONS[option][0],
PROGRAM_ENUM_OPTIONS[option][1][value],
)
)
elif option in PROGRAM_OPTIONS:
option_key = PROGRAM_OPTIONS[option][0]
options.append(Option(option_key, value))
method_call: Awaitable[Any]
exception_translation_key: str
if program:
program = (
program
if isinstance(program, ProgramKey)
else TRANSLATION_KEYS_PROGRAMS_MAP[program]
)
if affects_to == AFFECTS_TO_ACTIVE_PROGRAM:
method_call = client.start_program(
ha_id, program_key=program, options=options
)
exception_translation_key = "start_program"
elif affects_to == AFFECTS_TO_SELECTED_PROGRAM:
method_call = client.set_selected_program(
ha_id, program_key=program, options=options
)
exception_translation_key = "select_program"
else:
array_of_options = ArrayOfOptions(options)
if affects_to == AFFECTS_TO_ACTIVE_PROGRAM:
method_call = client.set_active_program_options(
ha_id, array_of_options=array_of_options
)
exception_translation_key = "set_options_active_program"
else:
# affects_to is AFFECTS_TO_SELECTED_PROGRAM
method_call = client.set_selected_program_options(
ha_id, array_of_options=array_of_options
)
exception_translation_key = "set_options_selected_program"
try:
await method_call
except HomeConnectError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key=exception_translation_key,
translation_placeholders={
**get_dict_from_home_connect_error(err),
**(
{SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program}
if program
else {}
),
},
) from err
async def async_service_start_program(call: ServiceCall):
"""Service for starting a program."""
await _async_service_program(call, True)
@ -315,6 +564,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async_service_start_program,
schema=SERVICE_PROGRAM_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SERVICE_SET_PROGRAM_AND_OPTIONS,
async_service_set_program_and_options,
schema=SERVICE_PROGRAM_AND_OPTIONS_SCHEMA,
)
return True
@ -349,6 +604,7 @@ async def async_unload_entry(
hass: HomeAssistant, entry: HomeConnectConfigEntry
) -> bool:
"""Unload a config entry."""
async_delete_issue(hass, DOMAIN, "deprecated_set_program_and_option_actions")
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -1,6 +1,10 @@
"""Constants for the Home Connect integration."""
from aiohomeconnect.model import EventKey, SettingKey, StatusKey
from typing import cast
from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey, StatusKey
from .utils import bsh_key_to_translation_key
DOMAIN = "home_connect"
@ -52,15 +56,18 @@ SERVICE_OPTION_SELECTED = "set_option_selected"
SERVICE_PAUSE_PROGRAM = "pause_program"
SERVICE_RESUME_PROGRAM = "resume_program"
SERVICE_SELECT_PROGRAM = "select_program"
SERVICE_SET_PROGRAM_AND_OPTIONS = "set_program_and_options"
SERVICE_SETTING = "change_setting"
SERVICE_START_PROGRAM = "start_program"
ATTR_AFFECTS_TO = "affects_to"
ATTR_KEY = "key"
ATTR_PROGRAM = "program"
ATTR_UNIT = "unit"
ATTR_VALUE = "value"
AFFECTS_TO_ACTIVE_PROGRAM = "active_program"
AFFECTS_TO_SELECTED_PROGRAM = "selected_program"
SVE_TRANSLATION_KEY_SET_SETTING = "set_setting_entity"
SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME = "appliance_name"
@ -70,6 +77,269 @@ SVE_TRANSLATION_PLACEHOLDER_KEY = "key"
SVE_TRANSLATION_PLACEHOLDER_VALUE = "value"
TRANSLATION_KEYS_PROGRAMS_MAP = {
bsh_key_to_translation_key(program.value): cast(ProgramKey, program)
for program in ProgramKey
if program != ProgramKey.UNKNOWN
}
PROGRAMS_TRANSLATION_KEYS_MAP = {
value: key for key, value in TRANSLATION_KEYS_PROGRAMS_MAP.items()
}
REFERENCE_MAP_ID_OPTIONS = {
bsh_key_to_translation_key(option): option
for option in (
"ConsumerProducts.CleaningRobot.EnumType.AvailableMaps.TempMap",
"ConsumerProducts.CleaningRobot.EnumType.AvailableMaps.Map1",
"ConsumerProducts.CleaningRobot.EnumType.AvailableMaps.Map2",
"ConsumerProducts.CleaningRobot.EnumType.AvailableMaps.Map3",
)
}
CLEANING_MODE_OPTIONS = {
bsh_key_to_translation_key(option): option
for option in (
"ConsumerProducts.CleaningRobot.EnumType.CleaningModes.Silent",
"ConsumerProducts.CleaningRobot.EnumType.CleaningModes.Standard",
"ConsumerProducts.CleaningRobot.EnumType.CleaningModes.Power",
)
}
BEAN_AMOUNT_OPTIONS = {
bsh_key_to_translation_key(option): option
for option in (
"ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.VeryMild",
"ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.Mild",
"ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.MildPlus",
"ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.Normal",
"ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.NormalPlus",
"ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.Strong",
"ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.StrongPlus",
"ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.VeryStrong",
"ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.VeryStrongPlus",
"ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.ExtraStrong",
"ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.DoubleShot",
"ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.DoubleShotPlus",
"ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.DoubleShotPlusPlus",
"ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.TripleShot",
"ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.TripleShotPlus",
"ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.CoffeeGround",
)
}
COFFEE_TEMPERATURE_OPTIONS = {
bsh_key_to_translation_key(option): option
for option in (
"ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.88C",
"ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.90C",
"ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.92C",
"ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.94C",
"ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.95C",
"ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.96C",
)
}
BEAN_CONTAINER_OPTIONS = {
bsh_key_to_translation_key(option): option
for option in (
"ConsumerProducts.CoffeeMaker.EnumType.BeanContainerSelection.Right",
"ConsumerProducts.CoffeeMaker.EnumType.BeanContainerSelection.Left",
)
}
FLOW_RATE_OPTIONS = {
bsh_key_to_translation_key(option): option
for option in (
"ConsumerProducts.CoffeeMaker.EnumType.FlowRate.Normal",
"ConsumerProducts.CoffeeMaker.EnumType.FlowRate.Intense",
"ConsumerProducts.CoffeeMaker.EnumType.FlowRate.IntensePlus",
)
}
COFFEE_MILK_RATIO_OPTIONS = {
bsh_key_to_translation_key(option): option
for option in (
"ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.10Percent",
"ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.20Percent",
"ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.25Percent",
"ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.30Percent",
"ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.40Percent",
"ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.50Percent",
"ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.55Percent",
"ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.60Percent",
"ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.65Percent",
"ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.67Percent",
"ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.70Percent",
"ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.75Percent",
"ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.80Percent",
"ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.85Percent",
"ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.90Percent",
)
}
HOT_WATER_TEMPERATURE_OPTIONS = {
bsh_key_to_translation_key(option): option
for option in (
"ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.WhiteTea",
"ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.GreenTea",
"ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.BlackTea",
"ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.50C",
"ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.55C",
"ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.60C",
"ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.65C",
"ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.70C",
"ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.75C",
"ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.80C",
"ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.85C",
"ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.90C",
"ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.95C",
"ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.97C",
"ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.122F",
"ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.131F",
"ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.140F",
"ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.149F",
"ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.158F",
"ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.167F",
"ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.176F",
"ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.185F",
"ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.194F",
"ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.203F",
"ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.Max",
)
}
DRYING_TARGET_OPTIONS = {
bsh_key_to_translation_key(option): option
for option in (
"LaundryCare.Dryer.EnumType.DryingTarget.IronDry",
"LaundryCare.Dryer.EnumType.DryingTarget.GentleDry",
"LaundryCare.Dryer.EnumType.DryingTarget.CupboardDry",
"LaundryCare.Dryer.EnumType.DryingTarget.CupboardDryPlus",
"LaundryCare.Dryer.EnumType.DryingTarget.ExtraDry",
)
}
VENTING_LEVEL_OPTIONS = {
bsh_key_to_translation_key(option): option
for option in (
"Cooking.Hood.EnumType.Stage.FanOff",
"Cooking.Hood.EnumType.Stage.FanStage01",
"Cooking.Hood.EnumType.Stage.FanStage02",
"Cooking.Hood.EnumType.Stage.FanStage03",
"Cooking.Hood.EnumType.Stage.FanStage04",
"Cooking.Hood.EnumType.Stage.FanStage05",
)
}
INTENSIVE_LEVEL_OPTIONS = {
bsh_key_to_translation_key(option): option
for option in (
"Cooking.Hood.EnumType.IntensiveStage.IntensiveStageOff",
"Cooking.Hood.EnumType.IntensiveStage.IntensiveStage1",
"Cooking.Hood.EnumType.IntensiveStage.IntensiveStage2",
)
}
WARMING_LEVEL_OPTIONS = {
bsh_key_to_translation_key(option): option
for option in (
"Cooking.Oven.EnumType.WarmingLevel.Low",
"Cooking.Oven.EnumType.WarmingLevel.Medium",
"Cooking.Oven.EnumType.WarmingLevel.High",
)
}
TEMPERATURE_OPTIONS = {
bsh_key_to_translation_key(option): option
for option in (
"LaundryCare.Washer.EnumType.Temperature.Cold",
"LaundryCare.Washer.EnumType.Temperature.GC20",
"LaundryCare.Washer.EnumType.Temperature.GC30",
"LaundryCare.Washer.EnumType.Temperature.GC40",
"LaundryCare.Washer.EnumType.Temperature.GC50",
"LaundryCare.Washer.EnumType.Temperature.GC60",
"LaundryCare.Washer.EnumType.Temperature.GC70",
"LaundryCare.Washer.EnumType.Temperature.GC80",
"LaundryCare.Washer.EnumType.Temperature.GC90",
"LaundryCare.Washer.EnumType.Temperature.UlCold",
"LaundryCare.Washer.EnumType.Temperature.UlWarm",
"LaundryCare.Washer.EnumType.Temperature.UlHot",
"LaundryCare.Washer.EnumType.Temperature.UlExtraHot",
)
}
SPIN_SPEED_OPTIONS = {
bsh_key_to_translation_key(option): option
for option in (
"LaundryCare.Washer.EnumType.SpinSpeed.Off",
"LaundryCare.Washer.EnumType.SpinSpeed.RPM400",
"LaundryCare.Washer.EnumType.SpinSpeed.RPM600",
"LaundryCare.Washer.EnumType.SpinSpeed.RPM800",
"LaundryCare.Washer.EnumType.SpinSpeed.RPM1000",
"LaundryCare.Washer.EnumType.SpinSpeed.RPM1200",
"LaundryCare.Washer.EnumType.SpinSpeed.RPM1400",
"LaundryCare.Washer.EnumType.SpinSpeed.RPM1600",
"LaundryCare.Washer.EnumType.SpinSpeed.UlOff",
"LaundryCare.Washer.EnumType.SpinSpeed.UlLow",
"LaundryCare.Washer.EnumType.SpinSpeed.UlMedium",
"LaundryCare.Washer.EnumType.SpinSpeed.UlHigh",
)
}
VARIO_PERFECT_OPTIONS = {
bsh_key_to_translation_key(option): option
for option in (
"LaundryCare.Common.EnumType.VarioPerfect.Off",
"LaundryCare.Common.EnumType.VarioPerfect.EcoPerfect",
"LaundryCare.Common.EnumType.VarioPerfect.SpeedPerfect",
)
}
PROGRAM_ENUM_OPTIONS = {
bsh_key_to_translation_key(option_key): (
option_key,
options,
)
for option_key, options in (
(
OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_REFERENCE_MAP_ID,
REFERENCE_MAP_ID_OPTIONS,
),
(
OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CLEANING_MODE,
CLEANING_MODE_OPTIONS,
),
(OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_AMOUNT, BEAN_AMOUNT_OPTIONS),
(
OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_TEMPERATURE,
COFFEE_TEMPERATURE_OPTIONS,
),
(
OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_CONTAINER_SELECTION,
BEAN_CONTAINER_OPTIONS,
),
(OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FLOW_RATE, FLOW_RATE_OPTIONS),
(
OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_MILK_RATIO,
COFFEE_MILK_RATIO_OPTIONS,
),
(
OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_HOT_WATER_TEMPERATURE,
HOT_WATER_TEMPERATURE_OPTIONS,
),
(OptionKey.LAUNDRY_CARE_DRYER_DRYING_TARGET, DRYING_TARGET_OPTIONS),
(OptionKey.COOKING_COMMON_HOOD_VENTING_LEVEL, VENTING_LEVEL_OPTIONS),
(OptionKey.COOKING_COMMON_HOOD_INTENSIVE_LEVEL, INTENSIVE_LEVEL_OPTIONS),
(OptionKey.COOKING_OVEN_WARMING_LEVEL, WARMING_LEVEL_OPTIONS),
(OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, TEMPERATURE_OPTIONS),
(OptionKey.LAUNDRY_CARE_WASHER_SPIN_SPEED, SPIN_SPEED_OPTIONS),
(OptionKey.LAUNDRY_CARE_COMMON_VARIO_PERFECT, VARIO_PERFECT_OPTIONS),
)
}
OLD_NEW_UNIQUE_ID_SUFFIX_MAP = {
"ChildLock": SettingKey.BSH_COMMON_CHILD_LOCK,
"Operation State": StatusKey.BSH_COMMON_OPERATION_STATE,

View File

@ -18,6 +18,9 @@
"set_option_selected": {
"service": "mdi:gesture-tap"
},
"set_program_and_options": {
"service": "mdi:form-select"
},
"change_setting": {
"service": "mdi:cog"
}

View File

@ -3,7 +3,7 @@
"name": "Home Connect",
"codeowners": ["@DavidMStraub", "@Diegorro98", "@MartinHjelmare"],
"config_flow": true,
"dependencies": ["application_credentials"],
"dependencies": ["application_credentials", "repairs"],
"documentation": "https://www.home-assistant.io/integrations/home_connect",
"iot_class": "cloud_push",
"loggers": ["aiohomeconnect"],

View File

@ -15,24 +15,20 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .common import setup_home_connect_entry
from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN, SVE_TRANSLATION_PLACEHOLDER_PROGRAM
from .const import (
APPLIANCES_WITH_PROGRAMS,
DOMAIN,
PROGRAMS_TRANSLATION_KEYS_MAP,
SVE_TRANSLATION_PLACEHOLDER_PROGRAM,
TRANSLATION_KEYS_PROGRAMS_MAP,
)
from .coordinator import (
HomeConnectApplianceData,
HomeConnectConfigEntry,
HomeConnectCoordinator,
)
from .entity import HomeConnectEntity
from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error
TRANSLATION_KEYS_PROGRAMS_MAP = {
bsh_key_to_translation_key(program.value): cast(ProgramKey, program)
for program in ProgramKey
if program != ProgramKey.UNKNOWN
}
PROGRAMS_TRANSLATION_KEYS_MAP = {
value: key for key, value in TRANSLATION_KEYS_PROGRAMS_MAP.items()
}
from .utils import get_dict_from_home_connect_error
@dataclass(frozen=True, kw_only=True)

View File

@ -46,6 +46,558 @@ select_program:
example: "seconds"
selector:
text:
set_program_and_options:
fields:
device_id:
required: true
selector:
device:
integration: home_connect
affects_to:
example: active_program
required: true
selector:
select:
translation_key: affects_to
options:
- active_program
- selected_program
program:
example: dishcare_dishwasher_program_auto2
required: true
selector:
select:
mode: dropdown
custom_value: false
translation_key: programs
options:
- consumer_products_cleaning_robot_program_cleaning_clean_all
- consumer_products_cleaning_robot_program_cleaning_clean_map
- consumer_products_cleaning_robot_program_basic_go_home
- consumer_products_coffee_maker_program_beverage_ristretto
- consumer_products_coffee_maker_program_beverage_espresso
- consumer_products_coffee_maker_program_beverage_espresso_doppio
- consumer_products_coffee_maker_program_beverage_coffee
- consumer_products_coffee_maker_program_beverage_x_l_coffee
- consumer_products_coffee_maker_program_beverage_caffe_grande
- consumer_products_coffee_maker_program_beverage_espresso_macchiato
- consumer_products_coffee_maker_program_beverage_cappuccino
- consumer_products_coffee_maker_program_beverage_latte_macchiato
- consumer_products_coffee_maker_program_beverage_caffe_latte
- consumer_products_coffee_maker_program_beverage_milk_froth
- consumer_products_coffee_maker_program_beverage_warm_milk
- consumer_products_coffee_maker_program_coffee_world_kleiner_brauner
- consumer_products_coffee_maker_program_coffee_world_grosser_brauner
- consumer_products_coffee_maker_program_coffee_world_verlaengerter
- consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun
- consumer_products_coffee_maker_program_coffee_world_wiener_melange
- consumer_products_coffee_maker_program_coffee_world_flat_white
- consumer_products_coffee_maker_program_coffee_world_cortado
- consumer_products_coffee_maker_program_coffee_world_cafe_cortado
- consumer_products_coffee_maker_program_coffee_world_cafe_con_leche
- consumer_products_coffee_maker_program_coffee_world_cafe_au_lait
- consumer_products_coffee_maker_program_coffee_world_doppio
- consumer_products_coffee_maker_program_coffee_world_kaapi
- consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd
- consumer_products_coffee_maker_program_coffee_world_galao
- consumer_products_coffee_maker_program_coffee_world_garoto
- consumer_products_coffee_maker_program_coffee_world_americano
- consumer_products_coffee_maker_program_coffee_world_red_eye
- consumer_products_coffee_maker_program_coffee_world_black_eye
- consumer_products_coffee_maker_program_coffee_world_dead_eye
- consumer_products_coffee_maker_program_beverage_hot_water
- dishcare_dishwasher_program_pre_rinse
- dishcare_dishwasher_program_auto_1
- dishcare_dishwasher_program_auto_2
- dishcare_dishwasher_program_auto_3
- dishcare_dishwasher_program_eco_50
- dishcare_dishwasher_program_quick_45
- dishcare_dishwasher_program_intensiv_70
- dishcare_dishwasher_program_normal_65
- dishcare_dishwasher_program_glas_40
- dishcare_dishwasher_program_glass_care
- dishcare_dishwasher_program_night_wash
- dishcare_dishwasher_program_quick_65
- dishcare_dishwasher_program_normal_45
- dishcare_dishwasher_program_intensiv_45
- dishcare_dishwasher_program_auto_half_load
- dishcare_dishwasher_program_intensiv_power
- dishcare_dishwasher_program_magic_daily
- dishcare_dishwasher_program_super_60
- dishcare_dishwasher_program_kurz_60
- dishcare_dishwasher_program_express_sparkle_65
- dishcare_dishwasher_program_machine_care
- dishcare_dishwasher_program_steam_fresh
- dishcare_dishwasher_program_maximum_cleaning
- dishcare_dishwasher_program_mixed_load
- laundry_care_dryer_program_cotton
- laundry_care_dryer_program_synthetic
- laundry_care_dryer_program_mix
- laundry_care_dryer_program_blankets
- laundry_care_dryer_program_business_shirts
- laundry_care_dryer_program_down_feathers
- laundry_care_dryer_program_hygiene
- laundry_care_dryer_program_jeans
- laundry_care_dryer_program_outdoor
- laundry_care_dryer_program_synthetic_refresh
- laundry_care_dryer_program_towels
- laundry_care_dryer_program_delicates
- laundry_care_dryer_program_super_40
- laundry_care_dryer_program_shirts_15
- laundry_care_dryer_program_pillow
- laundry_care_dryer_program_anti_shrink
- laundry_care_dryer_program_my_time_my_drying_time
- laundry_care_dryer_program_time_cold
- laundry_care_dryer_program_time_warm
- laundry_care_dryer_program_in_basket
- laundry_care_dryer_program_time_cold_fix_time_cold_20
- laundry_care_dryer_program_time_cold_fix_time_cold_30
- laundry_care_dryer_program_time_cold_fix_time_cold_60
- laundry_care_dryer_program_time_warm_fix_time_warm_30
- laundry_care_dryer_program_time_warm_fix_time_warm_40
- laundry_care_dryer_program_time_warm_fix_time_warm_60
- laundry_care_dryer_program_dessous
- cooking_common_program_hood_automatic
- cooking_common_program_hood_venting
- cooking_common_program_hood_delayed_shut_off
- cooking_oven_program_heating_mode_pre_heating
- cooking_oven_program_heating_mode_hot_air
- cooking_oven_program_heating_mode_hot_air_eco
- cooking_oven_program_heating_mode_hot_air_grilling
- cooking_oven_program_heating_mode_top_bottom_heating
- cooking_oven_program_heating_mode_top_bottom_heating_eco
- cooking_oven_program_heating_mode_bottom_heating
- cooking_oven_program_heating_mode_pizza_setting
- cooking_oven_program_heating_mode_slow_cook
- cooking_oven_program_heating_mode_intensive_heat
- cooking_oven_program_heating_mode_keep_warm
- cooking_oven_program_heating_mode_preheat_ovenware
- cooking_oven_program_heating_mode_frozen_heatup_special
- cooking_oven_program_heating_mode_desiccation
- cooking_oven_program_heating_mode_defrost
- cooking_oven_program_heating_mode_proof
- cooking_oven_program_heating_mode_hot_air_30_steam
- cooking_oven_program_heating_mode_hot_air_60_steam
- cooking_oven_program_heating_mode_hot_air_80_steam
- cooking_oven_program_heating_mode_hot_air_100_steam
- cooking_oven_program_heating_mode_sabbath_programme
- cooking_oven_program_microwave_90_watt
- cooking_oven_program_microwave_180_watt
- cooking_oven_program_microwave_360_watt
- cooking_oven_program_microwave_600_watt
- cooking_oven_program_microwave_900_watt
- cooking_oven_program_microwave_1000_watt
- cooking_oven_program_microwave_max
- cooking_oven_program_heating_mode_warming_drawer
- laundry_care_washer_program_cotton
- laundry_care_washer_program_cotton_cotton_eco
- laundry_care_washer_program_cotton_eco_4060
- laundry_care_washer_program_cotton_colour
- laundry_care_washer_program_easy_care
- laundry_care_washer_program_mix
- laundry_care_washer_program_mix_night_wash
- laundry_care_washer_program_delicates_silk
- laundry_care_washer_program_wool
- laundry_care_washer_program_sensitive
- laundry_care_washer_program_auto_30
- laundry_care_washer_program_auto_40
- laundry_care_washer_program_auto_60
- laundry_care_washer_program_chiffon
- laundry_care_washer_program_curtains
- laundry_care_washer_program_dark_wash
- laundry_care_washer_program_dessous
- laundry_care_washer_program_monsoon
- laundry_care_washer_program_outdoor
- laundry_care_washer_program_plush_toy
- laundry_care_washer_program_shirts_blouses
- laundry_care_washer_program_sport_fitness
- laundry_care_washer_program_towels
- laundry_care_washer_program_water_proof
- laundry_care_washer_program_power_speed_59
- laundry_care_washer_program_super_153045_super_15
- laundry_care_washer_program_super_153045_super_1530
- laundry_care_washer_program_down_duvet_duvet
- laundry_care_washer_program_rinse_rinse_spin_drain
- laundry_care_washer_program_drum_clean
- laundry_care_washer_dryer_program_cotton
- laundry_care_washer_dryer_program_cotton_eco_4060
- laundry_care_washer_dryer_program_mix
- laundry_care_washer_dryer_program_easy_care
- laundry_care_washer_dryer_program_wash_and_dry_60
- laundry_care_washer_dryer_program_wash_and_dry_90
cleaning_robot_options:
collapsed: true
fields:
consumer_products_cleaning_robot_option_reference_map_id:
example: consumer_products_cleaning_robot_enum_type_available_maps_map1
required: false
selector:
select:
mode: dropdown
translation_key: available_maps
options:
- consumer_products_cleaning_robot_enum_type_available_maps_temp_map
- consumer_products_cleaning_robot_enum_type_available_maps_map1
- consumer_products_cleaning_robot_enum_type_available_maps_map2
- consumer_products_cleaning_robot_enum_type_available_maps_map3
consumer_products_cleaning_robot_option_cleaning_mode:
example: consumer_products_cleaning_robot_enum_type_cleaning_modes_standard
required: false
selector:
select:
mode: dropdown
translation_key: cleaning_mode
options:
- consumer_products_cleaning_robot_enum_type_cleaning_modes_silent
- consumer_products_cleaning_robot_enum_type_cleaning_modes_standard
- consumer_products_cleaning_robot_enum_type_cleaning_modes_power
coffee_maker_options:
collapsed: true
fields:
consumer_products_coffee_maker_option_bean_amount:
example: consumer_products_coffee_maker_enum_type_bean_amount_normal
required: false
selector:
select:
mode: dropdown
translation_key: bean_amount
options:
- consumer_products_coffee_maker_enum_type_bean_amount_very_mild
- consumer_products_coffee_maker_enum_type_bean_amount_mild
- consumer_products_coffee_maker_enum_type_bean_amount_mild_plus
- consumer_products_coffee_maker_enum_type_bean_amount_normal
- consumer_products_coffee_maker_enum_type_bean_amount_normal_plus
- consumer_products_coffee_maker_enum_type_bean_amount_strong
- consumer_products_coffee_maker_enum_type_bean_amount_strong_plus
- consumer_products_coffee_maker_enum_type_bean_amount_very_strong
- consumer_products_coffee_maker_enum_type_bean_amount_very_strong_plus
- consumer_products_coffee_maker_enum_type_bean_amount_extra_strong
- consumer_products_coffee_maker_enum_type_bean_amount_double_shot
- consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus
- consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus_plus
- consumer_products_coffee_maker_enum_type_bean_amount_triple_shot
- consumer_products_coffee_maker_enum_type_bean_amount_triple_shot_plus
- consumer_products_coffee_maker_enum_type_bean_amount_coffee_ground
consumer_products_coffee_maker_option_fill_quantity:
example: 60
required: false
selector:
number:
min: 0
step: 1
mode: box
unit_of_measurement: ml
consumer_products_coffee_maker_option_coffee_temperature:
example: consumer_products_coffee_maker_enum_type_coffee_temperature_88_c
required: false
selector:
select:
mode: dropdown
translation_key: coffee_temperature
options:
- consumer_products_coffee_maker_enum_type_coffee_temperature_88_c
- consumer_products_coffee_maker_enum_type_coffee_temperature_90_c
- consumer_products_coffee_maker_enum_type_coffee_temperature_92_c
- consumer_products_coffee_maker_enum_type_coffee_temperature_94_c
- consumer_products_coffee_maker_enum_type_coffee_temperature_95_c
- consumer_products_coffee_maker_enum_type_coffee_temperature_96_c
consumer_products_coffee_maker_option_bean_container:
example: consumer_products_coffee_maker_enum_type_bean_container_selection_right
required: false
selector:
select:
mode: dropdown
translation_key: bean_container
options:
- consumer_products_coffee_maker_enum_type_bean_container_selection_right
- consumer_products_coffee_maker_enum_type_bean_container_selection_left
consumer_products_coffee_maker_option_flow_rate:
example: consumer_products_coffee_maker_enum_type_flow_rate_normal
required: false
selector:
select:
mode: dropdown
translation_key: flow_rate
options:
- consumer_products_coffee_maker_enum_type_flow_rate_normal
- consumer_products_coffee_maker_enum_type_flow_rate_intense
- consumer_products_coffee_maker_enum_type_flow_rate_intense_plus
consumer_products_coffee_maker_option_multiple_beverages:
example: false
required: false
selector:
boolean:
consumer_products_coffee_maker_option_coffee_milk_ratio:
example: consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent
required: false
selector:
select:
mode: dropdown
translation_key: coffee_milk_ratio
options:
- consumer_products_coffee_maker_enum_type_coffee_milk_ratio_10_percent
- consumer_products_coffee_maker_enum_type_coffee_milk_ratio_20_percent
- consumer_products_coffee_maker_enum_type_coffee_milk_ratio_25_percent
- consumer_products_coffee_maker_enum_type_coffee_milk_ratio_30_percent
- consumer_products_coffee_maker_enum_type_coffee_milk_ratio_40_percent
- consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent
- consumer_products_coffee_maker_enum_type_coffee_milk_ratio_55_percent
- consumer_products_coffee_maker_enum_type_coffee_milk_ratio_60_percent
- consumer_products_coffee_maker_enum_type_coffee_milk_ratio_65_percent
- consumer_products_coffee_maker_enum_type_coffee_milk_ratio_67_percent
- consumer_products_coffee_maker_enum_type_coffee_milk_ratio_70_percent
- consumer_products_coffee_maker_enum_type_coffee_milk_ratio_75_percent
- consumer_products_coffee_maker_enum_type_coffee_milk_ratio_80_percent
- consumer_products_coffee_maker_enum_type_coffee_milk_ratio_85_percent
- consumer_products_coffee_maker_enum_type_coffee_milk_ratio_90_percent
consumer_products_coffee_maker_option_hot_water_temperature:
example: consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c
required: false
selector:
select:
mode: dropdown
translation_key: hot_water_temperature
options:
- consumer_products_coffee_maker_enum_type_hot_water_temperature_white_tea
- consumer_products_coffee_maker_enum_type_hot_water_temperature_green_tea
- consumer_products_coffee_maker_enum_type_hot_water_temperature_black_tea
- consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c
- consumer_products_coffee_maker_enum_type_hot_water_temperature_55_c
- consumer_products_coffee_maker_enum_type_hot_water_temperature_60_c
- consumer_products_coffee_maker_enum_type_hot_water_temperature_65_c
- consumer_products_coffee_maker_enum_type_hot_water_temperature_70_c
- consumer_products_coffee_maker_enum_type_hot_water_temperature_75_c
- consumer_products_coffee_maker_enum_type_hot_water_temperature_80_c
- consumer_products_coffee_maker_enum_type_hot_water_temperature_85_c
- consumer_products_coffee_maker_enum_type_hot_water_temperature_90_c
- consumer_products_coffee_maker_enum_type_hot_water_temperature_95_c
- consumer_products_coffee_maker_enum_type_hot_water_temperature_97_c
- consumer_products_coffee_maker_enum_type_hot_water_temperature_122_f
- consumer_products_coffee_maker_enum_type_hot_water_temperature_131_f
- consumer_products_coffee_maker_enum_type_hot_water_temperature_140_f
- consumer_products_coffee_maker_enum_type_hot_water_temperature_149_f
- consumer_products_coffee_maker_enum_type_hot_water_temperature_158_f
- consumer_products_coffee_maker_enum_type_hot_water_temperature_167_f
- consumer_products_coffee_maker_enum_type_hot_water_temperature_176_f
- consumer_products_coffee_maker_enum_type_hot_water_temperature_185_f
- consumer_products_coffee_maker_enum_type_hot_water_temperature_194_f
- consumer_products_coffee_maker_enum_type_hot_water_temperature_203_f
- consumer_products_coffee_maker_enum_type_hot_water_temperature_max
dish_washer_options:
collapsed: true
fields:
b_s_h_common_option_start_in_relative:
example: 3600
required: false
selector:
number:
min: 0
step: 1
mode: box
unit_of_measurement: s
dishcare_dishwasher_option_intensiv_zone:
example: false
required: false
selector:
boolean:
dishcare_dishwasher_option_brilliance_dry:
example: false
required: false
selector:
boolean:
dishcare_dishwasher_option_vario_speed_plus:
example: false
required: false
selector:
boolean:
dishcare_dishwasher_option_silence_on_demand:
example: false
required: false
selector:
boolean:
dishcare_dishwasher_option_half_load:
example: false
required: false
selector:
boolean:
dishcare_dishwasher_option_extra_dry:
example: false
required: false
selector:
boolean:
dishcare_dishwasher_option_hygiene_plus:
example: false
required: false
selector:
boolean:
dishcare_dishwasher_option_eco_dry:
example: false
required: false
selector:
boolean:
dishcare_dishwasher_option_zeolite_dry:
example: false
required: false
selector:
boolean:
dryer_options:
collapsed: true
fields:
laundry_care_dryer_option_drying_target:
example: laundry_care_dryer_enum_type_drying_target_iron_dry
required: false
selector:
select:
mode: dropdown
translation_key: drying_target
options:
- laundry_care_dryer_enum_type_drying_target_iron_dry
- laundry_care_dryer_enum_type_drying_target_gentle_dry
- laundry_care_dryer_enum_type_drying_target_cupboard_dry
- laundry_care_dryer_enum_type_drying_target_cupboard_dry_plus
- laundry_care_dryer_enum_type_drying_target_extra_dry
hood_options:
collapsed: true
fields:
cooking_hood_option_venting_level:
example: cooking_hood_enum_type_stage_fan_stage01
required: false
selector:
select:
mode: dropdown
translation_key: venting_level
options:
- cooking_hood_enum_type_stage_fan_off
- cooking_hood_enum_type_stage_fan_stage01
- cooking_hood_enum_type_stage_fan_stage02
- cooking_hood_enum_type_stage_fan_stage03
- cooking_hood_enum_type_stage_fan_stage04
- cooking_hood_enum_type_stage_fan_stage05
cooking_hood_option_intensive_level:
example: cooking_hood_enum_type_intensive_stage_intensive_stage1
required: false
selector:
select:
mode: dropdown
translation_key: intensive_level
options:
- cooking_hood_enum_type_intensive_stage_intensive_stage_off
- cooking_hood_enum_type_intensive_stage_intensive_stage1
- cooking_hood_enum_type_intensive_stage_intensive_stage2
oven_options:
collapsed: true
fields:
cooking_oven_option_setpoint_temperature:
example: 180
required: false
selector:
number:
min: 0
step: 1
mode: box
unit_of_measurement: °C/°F
b_s_h_common_option_duration:
example: 900
required: false
selector:
number:
min: 0
step: 1
mode: box
unit_of_measurement: s
cooking_oven_option_fast_pre_heat:
example: false
required: false
selector:
boolean:
warming_drawer_options:
collapsed: true
fields:
cooking_oven_option_warming_level:
example: cooking_oven_enum_type_warming_level_medium
required: false
selector:
select:
mode: dropdown
translation_key: warming_level
options:
- cooking_oven_enum_type_warming_level_low
- cooking_oven_enum_type_warming_level_medium
- cooking_oven_enum_type_warming_level_high
washer_options:
collapsed: true
fields:
laundry_care_washer_option_temperature:
example: laundry_care_washer_enum_type_temperature_g_c40
required: false
selector:
select:
mode: dropdown
translation_key: washer_temperature
options:
- laundry_care_washer_enum_type_temperature_cold
- laundry_care_washer_enum_type_temperature_g_c20
- laundry_care_washer_enum_type_temperature_g_c30
- laundry_care_washer_enum_type_temperature_g_c40
- laundry_care_washer_enum_type_temperature_g_c50
- laundry_care_washer_enum_type_temperature_g_c60
- laundry_care_washer_enum_type_temperature_g_c70
- laundry_care_washer_enum_type_temperature_g_c80
- laundry_care_washer_enum_type_temperature_g_c90
- laundry_care_washer_enum_type_temperature_ul_cold
- laundry_care_washer_enum_type_temperature_ul_warm
- laundry_care_washer_enum_type_temperature_ul_hot
- laundry_care_washer_enum_type_temperature_ul_extra_hot
laundry_care_washer_option_spin_speed:
example: laundry_care_washer_enum_type_spin_speed_r_p_m800
required: false
selector:
select:
mode: dropdown
translation_key: spin_speed
options:
- laundry_care_washer_enum_type_spin_speed_off
- laundry_care_washer_enum_type_spin_speed_r_p_m400
- laundry_care_washer_enum_type_spin_speed_r_p_m600
- laundry_care_washer_enum_type_spin_speed_r_p_m800
- laundry_care_washer_enum_type_spin_speed_r_p_m1000
- laundry_care_washer_enum_type_spin_speed_r_p_m1200
- laundry_care_washer_enum_type_spin_speed_r_p_m1400
- laundry_care_washer_enum_type_spin_speed_r_p_m1600
- laundry_care_washer_enum_type_spin_speed_ul_off
- laundry_care_washer_enum_type_spin_speed_ul_low
- laundry_care_washer_enum_type_spin_speed_ul_medium
- laundry_care_washer_enum_type_spin_speed_ul_high
b_s_h_common_option_finish_in_relative:
example: 3600
required: false
selector:
number:
min: 0
step: 1
mode: box
unit_of_measurement: s
laundry_care_washer_option_i_dos1_active:
example: false
required: false
selector:
boolean:
laundry_care_washer_option_i_dos2_active:
example: false
required: false
selector:
boolean:
laundry_care_washer_option_vario_perfect:
example: laundry_care_common_enum_type_vario_perfect_eco_perfect
required: false
selector:
select:
mode: dropdown
translation_key: vario_perfect
options:
- laundry_care_common_enum_type_vario_perfect_off
- laundry_care_common_enum_type_vario_perfect_eco_perfect
- laundry_care_common_enum_type_vario_perfect_speed_perfect
pause_program:
fields:
device_id:

File diff suppressed because it is too large Load Diff

View File

@ -23,8 +23,10 @@ import voluptuous as vol
from homeassistant.components import onboarding
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import instance_id
from homeassistant.helpers.selector import TextSelector
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
@ -88,7 +90,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
# Tell device we want a token, user must now press the button within 30 seconds
# The first attempt will always fail, but this opens the window to press the button
token = await async_request_token(self.ip_address)
token = await async_request_token(self.hass, self.ip_address)
errors: dict[str, str] | None = None
if token is None:
@ -250,7 +252,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] | None = None
token = await async_request_token(self.ip_address)
token = await async_request_token(self.hass, self.ip_address)
if user_input is not None:
if token is None:
@ -353,7 +355,7 @@ async def async_try_connect(ip_address: str, token: str | None = None) -> Device
await energy_api.close()
async def async_request_token(ip_address: str) -> str | None:
async def async_request_token(hass: HomeAssistant, ip_address: str) -> str | None:
"""Try to request a token from the device.
This method is used to request a token from the device,
@ -362,8 +364,12 @@ async def async_request_token(ip_address: str) -> str | None:
api = HomeWizardEnergyV2(ip_address)
# Get a part of the unique id to make the token unique
# This is to prevent token conflicts when multiple HA instances are used
uuid = await instance_id.async_get(hass)
try:
return await api.get_token("home-assistant")
return await api.get_token(f"home-assistant#{uuid[:6]}")
except DisabledError:
return None
finally:

View File

@ -47,7 +47,7 @@ class MigrateToV2ApiRepairFlow(RepairsFlow):
# Tell device we want a token, user must now press the button within 30 seconds
# The first attempt will always fail, but this opens the window to press the button
token = await async_request_token(ip_address)
token = await async_request_token(self.hass, ip_address)
errors: dict[str, str] | None = None
if token is None:

View File

@ -58,7 +58,7 @@
"services": {
"send_raw_node_command": {
"name": "Send raw node command",
"description": "[%key:component::isy994::options::step::init::description%]",
"description": "Sends a “raw” (e.g., DON, DOF) ISY REST device command to a node using its Home Assistant entity ID. This is useful for devices that arent fully supported in Home Assistant yet, such as controls for many NodeServer nodes.",
"fields": {
"command": {
"name": "Command",

View File

@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import SCAN_INTERVAL
from .const import DOMAIN, SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
@ -75,16 +75,28 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]):
try:
# Fetch last hour of data
for sensor in self.devices:
sensor.data = (
await self.api.get_sensor_status(
sensor=sensor,
tz=self.hass.config.time_zone,
data = await self.api.get_sensor_status(
sensor=sensor,
tz=self.hass.config.time_zone,
)
_LOGGER.debug("Got data: %s", data)
if data_error := data.get("error"):
if data_error == "no_readings":
sensor.data = None
_LOGGER.debug("No readings for %s", sensor.name)
continue
_LOGGER.debug("Error: %s", data_error)
raise UpdateFailed(
translation_domain=DOMAIN, translation_key="update_error"
)
)["data"]["current"]
_LOGGER.debug("Got data: %s", sensor.data)
sensor.data = data["data"]["current"]
except HTTPError as error:
raise UpdateFailed from error
raise UpdateFailed(
translation_domain=DOMAIN, translation_key="update_error"
) from error
# Verify that we have permission to read the sensors
for sensor in self.devices:

View File

@ -64,6 +64,7 @@ SENSOR_DESCRIPTIONS = {
state_class=SensorStateClass.MEASUREMENT,
value_fn=get_value,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
suggested_display_precision=2,
),
"Humidity": LaCrosseSensorEntityDescription(
key="Humidity",
@ -71,6 +72,7 @@ SENSOR_DESCRIPTIONS = {
state_class=SensorStateClass.MEASUREMENT,
value_fn=get_value,
native_unit_of_measurement=PERCENTAGE,
suggested_display_precision=2,
),
"HeatIndex": LaCrosseSensorEntityDescription(
key="HeatIndex",
@ -79,6 +81,7 @@ SENSOR_DESCRIPTIONS = {
state_class=SensorStateClass.MEASUREMENT,
value_fn=get_value,
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
suggested_display_precision=2,
),
"WindSpeed": LaCrosseSensorEntityDescription(
key="WindSpeed",
@ -86,6 +89,7 @@ SENSOR_DESCRIPTIONS = {
value_fn=get_value,
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
device_class=SensorDeviceClass.WIND_SPEED,
suggested_display_precision=2,
),
"Rain": LaCrosseSensorEntityDescription(
key="Rain",
@ -93,12 +97,14 @@ SENSOR_DESCRIPTIONS = {
value_fn=get_value,
native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS,
device_class=SensorDeviceClass.PRECIPITATION,
suggested_display_precision=2,
),
"WindHeading": LaCrosseSensorEntityDescription(
key="WindHeading",
translation_key="wind_heading",
value_fn=get_value,
native_unit_of_measurement=DEGREE,
suggested_display_precision=2,
),
"WetDry": LaCrosseSensorEntityDescription(
key="WetDry",
@ -117,6 +123,7 @@ SENSOR_DESCRIPTIONS = {
value_fn=get_value,
device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE,
native_unit_of_measurement=UnitOfPressure.HPA,
suggested_display_precision=2,
),
"FeelsLike": LaCrosseSensorEntityDescription(
key="FeelsLike",
@ -125,6 +132,7 @@ SENSOR_DESCRIPTIONS = {
value_fn=get_value,
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
suggested_display_precision=2,
),
"WindChill": LaCrosseSensorEntityDescription(
key="WindChill",
@ -133,6 +141,7 @@ SENSOR_DESCRIPTIONS = {
value_fn=get_value,
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
suggested_display_precision=2,
),
}
# map of API returned unit of measurement strings to their corresponding unit of measurement

View File

@ -42,5 +42,10 @@
"name": "Wind chill"
}
}
},
"exceptions": {
"update_error": {
"message": "Error updating data"
}
}
}

View File

@ -22,7 +22,12 @@ from .const import (
)
from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator
PLATFORMS: list[Platform] = [Platform.SWITCH, Platform.TIME]
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.SENSOR,
Platform.SWITCH,
Platform.TIME,
]
async def async_setup_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> bool:

View File

@ -0,0 +1,122 @@
"""Support for LetPot binary sensor entities."""
from collections.abc import Callable
from dataclasses import dataclass
from letpot.models import DeviceFeature, LetPotDeviceStatus
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator
from .entity import LetPotEntity, LetPotEntityDescription
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class LetPotBinarySensorEntityDescription(
LetPotEntityDescription, BinarySensorEntityDescription
):
"""Describes a LetPot binary sensor entity."""
is_on_fn: Callable[[LetPotDeviceStatus], bool]
BINARY_SENSORS: tuple[LetPotBinarySensorEntityDescription, ...] = (
LetPotBinarySensorEntityDescription(
key="low_nutrients",
translation_key="low_nutrients",
is_on_fn=lambda status: bool(status.errors.low_nutrients),
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
device_class=BinarySensorDeviceClass.PROBLEM,
supported_fn=(
lambda coordinator: coordinator.data.errors.low_nutrients is not None
),
),
LetPotBinarySensorEntityDescription(
key="low_water",
translation_key="low_water",
is_on_fn=lambda status: bool(status.errors.low_water),
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
device_class=BinarySensorDeviceClass.PROBLEM,
supported_fn=lambda coordinator: coordinator.data.errors.low_water is not None,
),
LetPotBinarySensorEntityDescription(
key="pump",
translation_key="pump",
is_on_fn=lambda status: status.pump_status == 1,
device_class=BinarySensorDeviceClass.RUNNING,
supported_fn=(
lambda coordinator: DeviceFeature.PUMP_STATUS
in coordinator.device_client.device_features
),
),
LetPotBinarySensorEntityDescription(
key="pump_error",
translation_key="pump_error",
is_on_fn=lambda status: bool(status.errors.pump_malfunction),
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
device_class=BinarySensorDeviceClass.PROBLEM,
supported_fn=(
lambda coordinator: coordinator.data.errors.pump_malfunction is not None
),
),
LetPotBinarySensorEntityDescription(
key="refill_error",
translation_key="refill_error",
is_on_fn=lambda status: bool(status.errors.refill_error),
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
device_class=BinarySensorDeviceClass.PROBLEM,
supported_fn=(
lambda coordinator: coordinator.data.errors.refill_error is not None
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: LetPotConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LetPot binary sensor entities based on a config entry and device status/features."""
coordinators = entry.runtime_data
async_add_entities(
LetPotBinarySensorEntity(coordinator, description)
for description in BINARY_SENSORS
for coordinator in coordinators
if description.supported_fn(coordinator)
)
class LetPotBinarySensorEntity(LetPotEntity, BinarySensorEntity):
"""Defines a LetPot binary sensor entity."""
entity_description: LetPotBinarySensorEntityDescription
def __init__(
self,
coordinator: LetPotDeviceCoordinator,
description: LetPotBinarySensorEntityDescription,
) -> None:
"""Initialize LetPot binary sensor entity."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{coordinator.device.serial_number}_{description.key}"
@property
def is_on(self) -> bool:
"""Return if the binary sensor is on."""
return self.entity_description.is_on_fn(self.coordinator.data)

View File

@ -1,18 +1,27 @@
"""Base class for LetPot entities."""
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any, Concatenate
from letpot.exceptions import LetPotConnectionException, LetPotException
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import LetPotDeviceCoordinator
@dataclass(frozen=True, kw_only=True)
class LetPotEntityDescription(EntityDescription):
"""Description for all LetPot entities."""
supported_fn: Callable[[LetPotDeviceCoordinator], bool] = lambda _: True
class LetPotEntity(CoordinatorEntity[LetPotDeviceCoordinator]):
"""Defines a base LetPot entity."""

View File

@ -1,5 +1,30 @@
{
"entity": {
"binary_sensor": {
"low_nutrients": {
"default": "mdi:beaker-alert",
"state": {
"off": "mdi:beaker"
}
},
"low_water": {
"default": "mdi:water-percent-alert",
"state": {
"off": "mdi:water-percent"
}
},
"pump": {
"default": "mdi:pump",
"state": {
"off": "mdi:pump-off"
}
}
},
"sensor": {
"water_level": {
"default": "mdi:water-percent"
}
},
"switch": {
"alarm_sound": {
"default": "mdi:bell-ring",

View File

@ -44,7 +44,7 @@ rules:
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: done
test-coverage: todo
test-coverage: done
# Gold
devices: done
@ -59,9 +59,9 @@ rules:
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations: done

View File

@ -0,0 +1,110 @@
"""Support for LetPot sensor entities."""
from collections.abc import Callable
from dataclasses import dataclass
from letpot.models import DeviceFeature, LetPotDeviceStatus, TemperatureUnit
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator
from .entity import LetPotEntity, LetPotEntityDescription
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
LETPOT_TEMPERATURE_UNIT_HA_UNIT = {
TemperatureUnit.CELSIUS: UnitOfTemperature.CELSIUS,
TemperatureUnit.FAHRENHEIT: UnitOfTemperature.FAHRENHEIT,
}
@dataclass(frozen=True, kw_only=True)
class LetPotSensorEntityDescription(LetPotEntityDescription, SensorEntityDescription):
"""Describes a LetPot sensor entity."""
native_unit_of_measurement_fn: Callable[[LetPotDeviceStatus], str | None]
value_fn: Callable[[LetPotDeviceStatus], StateType]
SENSORS: tuple[LetPotSensorEntityDescription, ...] = (
LetPotSensorEntityDescription(
key="temperature",
value_fn=lambda status: status.temperature_value,
native_unit_of_measurement_fn=(
lambda status: LETPOT_TEMPERATURE_UNIT_HA_UNIT[
status.temperature_unit or TemperatureUnit.CELSIUS
]
),
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
supported_fn=(
lambda coordinator: DeviceFeature.TEMPERATURE
in coordinator.device_client.device_features
),
),
LetPotSensorEntityDescription(
key="water_level",
translation_key="water_level",
value_fn=lambda status: status.water_level,
native_unit_of_measurement_fn=lambda _: PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
supported_fn=(
lambda coordinator: DeviceFeature.WATER_LEVEL
in coordinator.device_client.device_features
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: LetPotConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LetPot sensor entities based on a device features."""
coordinators = entry.runtime_data
async_add_entities(
LetPotSensorEntity(coordinator, description)
for description in SENSORS
for coordinator in coordinators
if description.supported_fn(coordinator)
)
class LetPotSensorEntity(LetPotEntity, SensorEntity):
"""Defines a LetPot sensor entity."""
entity_description: LetPotSensorEntityDescription
def __init__(
self,
coordinator: LetPotDeviceCoordinator,
description: LetPotSensorEntityDescription,
) -> None:
"""Initialize LetPot sensor entity."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{coordinator.device.serial_number}_{description.key}"
@property
def native_unit_of_measurement(self) -> str | None:
"""Return the native unit of measurement."""
return self.entity_description.native_unit_of_measurement_fn(
self.coordinator.data
)
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data)

View File

@ -32,6 +32,28 @@
}
},
"entity": {
"binary_sensor": {
"low_nutrients": {
"name": "Low nutrients"
},
"low_water": {
"name": "Low water"
},
"pump": {
"name": "Pump"
},
"pump_error": {
"name": "Pump error"
},
"refill_error": {
"name": "Refill error"
}
},
"sensor": {
"water_level": {
"name": "Water level"
}
},
"switch": {
"alarm_sound": {
"name": "Alarm sound"

View File

@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator
from .entity import LetPotEntity, exception_handler
from .entity import LetPotEntity, LetPotEntityDescription, exception_handler
# Each change pushes a 'full' device status with the change. The library will cache
# pending changes to avoid overwriting, but try to avoid a lot of parallelism.
@ -21,14 +21,33 @@ PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class LetPotSwitchEntityDescription(SwitchEntityDescription):
class LetPotSwitchEntityDescription(LetPotEntityDescription, SwitchEntityDescription):
"""Describes a LetPot switch entity."""
value_fn: Callable[[LetPotDeviceStatus], bool | None]
set_value_fn: Callable[[LetPotDeviceClient, bool], Coroutine[Any, Any, None]]
BASE_SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = (
SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = (
LetPotSwitchEntityDescription(
key="alarm_sound",
translation_key="alarm_sound",
value_fn=lambda status: status.system_sound,
set_value_fn=lambda device_client, value: device_client.set_sound(value),
entity_category=EntityCategory.CONFIG,
supported_fn=lambda coordinator: coordinator.data.system_sound is not None,
),
LetPotSwitchEntityDescription(
key="auto_mode",
translation_key="auto_mode",
value_fn=lambda status: status.water_mode == 1,
set_value_fn=lambda device_client, value: device_client.set_water_mode(value),
entity_category=EntityCategory.CONFIG,
supported_fn=(
lambda coordinator: DeviceFeature.PUMP_AUTO
in coordinator.device_client.device_features
),
),
LetPotSwitchEntityDescription(
key="power",
translation_key="power",
@ -44,20 +63,6 @@ BASE_SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = (
entity_category=EntityCategory.CONFIG,
),
)
ALARM_SWITCH: LetPotSwitchEntityDescription = LetPotSwitchEntityDescription(
key="alarm_sound",
translation_key="alarm_sound",
value_fn=lambda status: status.system_sound,
set_value_fn=lambda device_client, value: device_client.set_sound(value),
entity_category=EntityCategory.CONFIG,
)
AUTO_MODE_SWITCH: LetPotSwitchEntityDescription = LetPotSwitchEntityDescription(
key="auto_mode",
translation_key="auto_mode",
value_fn=lambda status: status.water_mode == 1,
set_value_fn=lambda device_client, value: device_client.set_water_mode(value),
entity_category=EntityCategory.CONFIG,
)
async def async_setup_entry(
@ -69,19 +74,10 @@ async def async_setup_entry(
coordinators = entry.runtime_data
entities: list[SwitchEntity] = [
LetPotSwitchEntity(coordinator, description)
for description in BASE_SWITCHES
for description in SWITCHES
for coordinator in coordinators
if description.supported_fn(coordinator)
]
entities.extend(
LetPotSwitchEntity(coordinator, ALARM_SWITCH)
for coordinator in coordinators
if coordinator.data.system_sound is not None
)
entities.extend(
LetPotSwitchEntity(coordinator, AUTO_MODE_SWITCH)
for coordinator in coordinators
if DeviceFeature.PUMP_AUTO in coordinator.device_client.device_features
)
async_add_entities(entities)

View File

@ -6,6 +6,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@ -31,6 +32,8 @@ class MotionMountMovingSensor(MotionMountEntity, BinarySensorEntity):
_attr_device_class = BinarySensorDeviceClass.MOVING
_attr_translation_key = "motionmount_is_moving"
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_entity_registry_enabled_default = False
def __init__(
self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry

View File

@ -0,0 +1,12 @@
{
"entity": {
"sensor": {
"motionmount_error_status": {
"default": "mdi:alert-circle-outline",
"state": {
"none": "mdi:check-circle-outline"
}
}
}
}
}

View File

@ -56,12 +56,12 @@ rules:
dynamic-devices:
status: exempt
comment: Single device per config entry
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations: todo
icon-translations: done
reconfiguration-flow: todo
repair-issues:
status: exempt

View File

@ -6,6 +6,7 @@ import motionmount
from motionmount import MotionMountSystemError
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@ -47,6 +48,8 @@ class MotionMountErrorStatusSensor(MotionMountEntity, SensorEntity):
"internal",
]
_attr_translation_key = "motionmount_error_status"
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_entity_registry_enabled_default = False
def __init__(
self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry

View File

@ -6,7 +6,14 @@ from functools import lru_cache
from types import TracebackType
from typing import Self
from paho.mqtt.client import Client as MQTTClient
from paho.mqtt.client import (
CallbackOnConnect_v2,
CallbackOnDisconnect_v2,
CallbackOnPublish_v2,
CallbackOnSubscribe_v2,
CallbackOnUnsubscribe_v2,
Client as MQTTClient,
)
_MQTT_LOCK_COUNT = 7
@ -44,6 +51,12 @@ class AsyncMQTTClient(MQTTClient):
that is not needed since we are running in an async event loop.
"""
on_connect: CallbackOnConnect_v2
on_disconnect: CallbackOnDisconnect_v2
on_publish: CallbackOnPublish_v2
on_subscribe: CallbackOnSubscribe_v2
on_unsubscribe: CallbackOnUnsubscribe_v2
def setup(self) -> None:
"""Set up the client.

View File

@ -311,8 +311,8 @@ class MqttClientSetup:
client_id = None
transport: str = config.get(CONF_TRANSPORT, DEFAULT_TRANSPORT)
self._client = AsyncMQTTClient(
mqtt.CallbackAPIVersion.VERSION1,
client_id,
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
client_id=client_id,
protocol=proto,
transport=transport, # type: ignore[arg-type]
reconnect_on_failure=False,
@ -476,9 +476,9 @@ class MQTT:
mqttc.on_connect = self._async_mqtt_on_connect
mqttc.on_disconnect = self._async_mqtt_on_disconnect
mqttc.on_message = self._async_mqtt_on_message
mqttc.on_publish = self._async_mqtt_on_callback
mqttc.on_subscribe = self._async_mqtt_on_callback
mqttc.on_unsubscribe = self._async_mqtt_on_callback
mqttc.on_publish = self._async_mqtt_on_publish
mqttc.on_subscribe = self._async_mqtt_on_subscribe_unsubscribe
mqttc.on_unsubscribe = self._async_mqtt_on_subscribe_unsubscribe
# suppress exceptions at callback
mqttc.suppress_exceptions = True
@ -498,7 +498,7 @@ class MQTT:
def _async_reader_callback(self, client: mqtt.Client) -> None:
"""Handle reading data from the socket."""
if (status := client.loop_read(MAX_PACKETS_TO_READ)) != 0:
self._async_on_disconnect(status)
self._async_handle_callback_exception(status)
@callback
def _async_start_misc_periodic(self) -> None:
@ -593,7 +593,7 @@ class MQTT:
def _async_writer_callback(self, client: mqtt.Client) -> None:
"""Handle writing data to the socket."""
if (status := client.loop_write()) != 0:
self._async_on_disconnect(status)
self._async_handle_callback_exception(status)
def _on_socket_register_write(
self, client: mqtt.Client, userdata: Any, sock: SocketType
@ -983,9 +983,9 @@ class MQTT:
self,
_mqttc: mqtt.Client,
_userdata: None,
_flags: dict[str, int],
result_code: int,
properties: mqtt.Properties | None = None,
_connect_flags: mqtt.ConnectFlags,
reason_code: mqtt.ReasonCode,
_properties: mqtt.Properties | None = None,
) -> None:
"""On connect callback.
@ -993,19 +993,20 @@ class MQTT:
message.
"""
# pylint: disable-next=import-outside-toplevel
import paho.mqtt.client as mqtt
if result_code != mqtt.CONNACK_ACCEPTED:
if result_code in (
mqtt.CONNACK_REFUSED_BAD_USERNAME_PASSWORD,
mqtt.CONNACK_REFUSED_NOT_AUTHORIZED,
):
if reason_code.is_failure:
# 24: Continue authentication
# 25: Re-authenticate
# 134: Bad user name or password
# 135: Not authorized
# 140: Bad authentication method
if reason_code.value in (24, 25, 134, 135, 140):
self._should_reconnect = False
self.hass.async_create_task(self.async_disconnect())
self.config_entry.async_start_reauth(self.hass)
_LOGGER.error(
"Unable to connect to the MQTT broker: %s",
mqtt.connack_string(result_code),
reason_code.getName(), # type: ignore[no-untyped-call]
)
self._async_connection_result(False)
return
@ -1016,7 +1017,7 @@ class MQTT:
"Connected to MQTT server %s:%s (%s)",
self.conf[CONF_BROKER],
self.conf.get(CONF_PORT, DEFAULT_PORT),
result_code,
reason_code,
)
birth: dict[str, Any]
@ -1153,18 +1154,32 @@ class MQTT:
self._mqtt_data.state_write_requests.process_write_state_requests(msg)
@callback
def _async_mqtt_on_callback(
def _async_mqtt_on_publish(
self,
_mqttc: mqtt.Client,
_userdata: None,
mid: int,
_granted_qos_reason: tuple[int, ...] | mqtt.ReasonCodes | None = None,
_properties_reason: mqtt.ReasonCodes | None = None,
_reason_code: mqtt.ReasonCode,
_properties: mqtt.Properties | None,
) -> None:
"""Publish callback."""
self._async_mqtt_on_callback(mid)
@callback
def _async_mqtt_on_subscribe_unsubscribe(
self,
_mqttc: mqtt.Client,
_userdata: None,
mid: int,
_reason_code: list[mqtt.ReasonCode],
_properties: mqtt.Properties | None,
) -> None:
"""Subscribe / Unsubscribe callback."""
self._async_mqtt_on_callback(mid)
@callback
def _async_mqtt_on_callback(self, mid: int) -> None:
"""Publish / Subscribe / Unsubscribe callback."""
# The callback signature for on_unsubscribe is different from on_subscribe
# see https://github.com/eclipse/paho.mqtt.python/issues/687
# properties and reason codes are not used in Home Assistant
future = self._async_get_mid_future(mid)
if future.done() and (future.cancelled() or future.exception()):
# Timed out or cancelled
@ -1180,19 +1195,28 @@ class MQTT:
self._pending_operations[mid] = future
return future
@callback
def _async_handle_callback_exception(self, status: mqtt.MQTTErrorCode) -> None:
"""Handle a callback exception."""
# We don't import on the top because some integrations
# should be able to optionally rely on MQTT.
import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel
_LOGGER.warning(
"Error returned from MQTT server: %s",
mqtt.error_string(status),
)
@callback
def _async_mqtt_on_disconnect(
self,
_mqttc: mqtt.Client,
_userdata: None,
result_code: int,
_disconnect_flags: mqtt.DisconnectFlags,
reason_code: mqtt.ReasonCode,
properties: mqtt.Properties | None = None,
) -> None:
"""Disconnected callback."""
self._async_on_disconnect(result_code)
@callback
def _async_on_disconnect(self, result_code: int) -> None:
if not self.connected:
# This function is re-entrant and may be called multiple times
# when there is a broken pipe error.
@ -1203,11 +1227,11 @@ class MQTT:
self.connected = False
async_dispatcher_send(self.hass, MQTT_CONNECTION_STATE, False)
_LOGGER.log(
logging.INFO if result_code == 0 else logging.DEBUG,
logging.INFO if reason_code == 0 else logging.DEBUG,
"Disconnected from MQTT server %s:%s (%s)",
self.conf[CONF_BROKER],
self.conf.get(CONF_PORT, DEFAULT_PORT),
result_code,
reason_code,
)
@callback

View File

@ -1023,14 +1023,14 @@ def try_connection(
result: queue.Queue[bool] = queue.Queue(maxsize=1)
def on_connect(
client_: mqtt.Client,
userdata: None,
flags: dict[str, Any],
result_code: int,
properties: mqtt.Properties | None = None,
_mqttc: mqtt.Client,
_userdata: None,
_connect_flags: mqtt.ConnectFlags,
reason_code: mqtt.ReasonCode,
_properties: mqtt.Properties | None = None,
) -> None:
"""Handle connection result."""
result.put(result_code == mqtt.CONNACK_ACCEPTED)
result.put(not reason_code.is_failure)
client.on_connect = on_connect

View File

@ -20,7 +20,7 @@ from .const import (
PUBLIC_TARGET_IP,
)
from .models import Adapter
from .network import Network, async_get_network
from .network import Network, async_get_loaded_network, async_get_network
_LOGGER = logging.getLogger(__name__)
@ -34,6 +34,12 @@ async def async_get_adapters(hass: HomeAssistant) -> list[Adapter]:
return network.adapters
@callback
def async_get_loaded_adapters(hass: HomeAssistant) -> list[Adapter]:
"""Get the network adapter configuration."""
return async_get_loaded_network(hass).adapters
@bind_hass
async def async_get_source_ip(
hass: HomeAssistant, target_ip: str | UndefinedType = UNDEFINED
@ -74,7 +80,14 @@ async def async_get_enabled_source_ips(
hass: HomeAssistant,
) -> list[IPv4Address | IPv6Address]:
"""Build the list of enabled source ips."""
adapters = await async_get_adapters(hass)
return async_get_enabled_source_ips_from_adapters(await async_get_adapters(hass))
@callback
def async_get_enabled_source_ips_from_adapters(
adapters: list[Adapter],
) -> list[IPv4Address | IPv6Address]:
"""Build the list of enabled source ips."""
sources: list[IPv4Address | IPv6Address] = []
for adapter in adapters:
if not adapter["enabled"]:
@ -151,5 +164,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async_register_websocket_commands,
)
await async_get_network(hass)
async_register_websocket_commands(hass)
return True

View File

@ -12,8 +12,6 @@ DOMAIN: Final = "network"
STORAGE_KEY: Final = "core.network"
STORAGE_VERSION: Final = 1
DATA_NETWORK: Final = "network"
ATTR_ADAPTERS: Final = "adapters"
ATTR_CONFIGURED_ADAPTERS: Final = "configured_adapters"
DEFAULT_CONFIGURED_ADAPTERS: list[str] = []

View File

@ -9,11 +9,12 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.storage import Store
from homeassistant.util.async_ import create_eager_task
from homeassistant.util.hass_dict import HassKey
from .const import (
ATTR_CONFIGURED_ADAPTERS,
DATA_NETWORK,
DEFAULT_CONFIGURED_ADAPTERS,
DOMAIN,
STORAGE_KEY,
STORAGE_VERSION,
)
@ -22,8 +23,16 @@ from .util import async_load_adapters, enable_adapters, enable_auto_detected_ada
_LOGGER = logging.getLogger(__name__)
DATA_NETWORK: HassKey[Network] = HassKey(DOMAIN)
@singleton(DATA_NETWORK)
@callback
def async_get_loaded_network(hass: HomeAssistant) -> Network:
"""Get network singleton."""
return hass.data[DATA_NETWORK]
@singleton(DOMAIN)
async def async_get_network(hass: HomeAssistant) -> Network:
"""Get network singleton."""
network = Network(hass)

View File

@ -153,7 +153,7 @@ class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]):
)
try:
if datetime.now().timestamp() >= expiry_time:
await self._update_refresh_token()
await self.update_refresh_token()
else:
await self.api.authenticate_refresh(
self.refresh_token, async_get_clientsession(self.hass)
@ -178,7 +178,7 @@ class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]):
else:
self.async_set_updated_data(devices)
async def _update_refresh_token(self) -> None:
async def update_refresh_token(self) -> None:
"""Update the refresh token with Nice G.O. API."""
_LOGGER.debug("Updating the refresh token with Nice G.O. API")
try:

View File

@ -2,21 +2,17 @@
from typing import Any
from aiohttp import ClientError
from nice_go import ApiError
from homeassistant.components.cover import (
CoverDeviceClass,
CoverEntity,
CoverEntityFeature,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import NiceGOConfigEntry
from .entity import NiceGOEntity
from .util import retry
DEVICE_CLASSES = {
"WallStation": CoverDeviceClass.GARAGE,
@ -71,30 +67,18 @@ class NiceGOCoverEntity(NiceGOEntity, CoverEntity):
"""Return if cover is closing."""
return self.data.barrier_status == "closing"
@retry("close_cover_error")
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the garage door."""
if self.is_closed:
return
try:
await self.coordinator.api.close_barrier(self._device_id)
except (ApiError, ClientError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="close_cover_error",
translation_placeholders={"exception": str(err)},
) from err
await self.coordinator.api.close_barrier(self._device_id)
@retry("open_cover_error")
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the garage door."""
if self.is_opened:
return
try:
await self.coordinator.api.open_barrier(self._device_id)
except (ApiError, ClientError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="open_cover_error",
translation_placeholders={"exception": str(err)},
) from err
await self.coordinator.api.open_barrier(self._device_id)

View File

@ -3,23 +3,19 @@
import logging
from typing import TYPE_CHECKING, Any
from aiohttp import ClientError
from nice_go import ApiError
from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
DOMAIN,
KNOWN_UNSUPPORTED_DEVICE_TYPES,
SUPPORTED_DEVICE_TYPES,
UNSUPPORTED_DEVICE_WARNING,
)
from .coordinator import NiceGOConfigEntry
from .entity import NiceGOEntity
from .util import retry
_LOGGER = logging.getLogger(__name__)
@ -63,26 +59,14 @@ class NiceGOLightEntity(NiceGOEntity, LightEntity):
assert self.data.light_status is not None
return self.data.light_status
@retry("light_on_error")
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the light."""
try:
await self.coordinator.api.light_on(self._device_id)
except (ApiError, ClientError) as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="light_on_error",
translation_placeholders={"exception": str(error)},
) from error
await self.coordinator.api.light_on(self._device_id)
@retry("light_off_error")
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the light."""
try:
await self.coordinator.api.light_off(self._device_id)
except (ApiError, ClientError) as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="light_off_error",
translation_placeholders={"exception": str(error)},
) from error
await self.coordinator.api.light_off(self._device_id)

View File

@ -5,23 +5,19 @@ from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from aiohttp import ClientError
from nice_go import ApiError
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
DOMAIN,
KNOWN_UNSUPPORTED_DEVICE_TYPES,
SUPPORTED_DEVICE_TYPES,
UNSUPPORTED_DEVICE_WARNING,
)
from .coordinator import NiceGOConfigEntry
from .entity import NiceGOEntity
from .util import retry
_LOGGER = logging.getLogger(__name__)
@ -65,26 +61,14 @@ class NiceGOSwitchEntity(NiceGOEntity, SwitchEntity):
assert self.data.vacation_mode is not None
return self.data.vacation_mode
@retry("switch_on_error")
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
try:
await self.coordinator.api.vacation_mode_on(self.data.id)
except (ApiError, ClientError) as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="switch_on_error",
translation_placeholders={"exception": str(error)},
) from error
await self.coordinator.api.vacation_mode_on(self.data.id)
@retry("switch_off_error")
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
try:
await self.coordinator.api.vacation_mode_off(self.data.id)
except (ApiError, ClientError) as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="switch_off_error",
translation_placeholders={"exception": str(error)},
) from error
await self.coordinator.api.vacation_mode_off(self.data.id)

View File

@ -0,0 +1,66 @@
"""Utilities for Nice G.O."""
from collections.abc import Callable, Coroutine
from functools import wraps
from typing import Any, Protocol, runtime_checkable
from aiohttp import ClientError
from nice_go import ApiError, AuthFailedError
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
from homeassistant.helpers.update_coordinator import UpdateFailed
from .const import DOMAIN
@runtime_checkable
class _ArgsProtocol(Protocol):
coordinator: Any
hass: Any
def retry[_R, **P](
translation_key: str,
) -> Callable[
[Callable[P, Coroutine[Any, Any, _R]]], Callable[P, Coroutine[Any, Any, _R]]
]:
"""Retry decorator to handle API errors."""
def decorator(
func: Callable[P, Coroutine[Any, Any, _R]],
) -> Callable[P, Coroutine[Any, Any, _R]]:
@wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs):
instance = args[0]
if not isinstance(instance, _ArgsProtocol):
raise TypeError("First argument must have correct attributes")
try:
return await func(*args, **kwargs)
except (ApiError, ClientError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key=translation_key,
translation_placeholders={"exception": str(err)},
) from err
except AuthFailedError:
# Try refreshing token and retry
try:
await instance.coordinator.update_refresh_token()
return await func(*args, **kwargs)
except (ApiError, ClientError, UpdateFailed) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key=translation_key,
translation_placeholders={"exception": str(err)},
) from err
except (AuthFailedError, ConfigEntryAuthFailed) as err:
instance.coordinator.config_entry.async_start_reauth(instance.hass)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key=translation_key,
translation_placeholders={"exception": str(err)},
) from err
return wrapper
return decorator

View File

@ -24,7 +24,7 @@
},
"data": {
"name": "Data",
"description": "Some integrations provide extended functionality. For information on how to use _data_, refer to the integration documentation."
"description": "Some integrations provide extended functionality via this field. For more information, refer to the integration documentation."
}
}
},
@ -56,7 +56,7 @@
},
"data": {
"name": "Data",
"description": "Some integrations provide extended functionality. For information on how to use _data_, refer to the integration documentation.."
"description": "Some integrations provide extended functionality via this field. For more information, refer to the integration documentation."
}
}
}

View File

@ -8,12 +8,14 @@ from datetime import timedelta
import logging
from onedrive_personal_sdk import OneDriveClient
from onedrive_personal_sdk.const import DriveState
from onedrive_personal_sdk.exceptions import AuthenticationError, OneDriveException
from onedrive_personal_sdk.models.items import Drive
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@ -67,4 +69,27 @@ class OneDriveUpdateCoordinator(DataUpdateCoordinator[Drive]):
raise UpdateFailed(
translation_domain=DOMAIN, translation_key="update_failed"
) from err
# create an issue if the drive is almost full
if drive.quota and (state := drive.quota.state) in (
DriveState.CRITICAL,
DriveState.EXCEEDED,
):
key = "drive_full" if state is DriveState.EXCEEDED else "drive_almost_full"
ir.async_create_issue(
self.hass,
DOMAIN,
key,
is_fixable=False,
severity=(
ir.IssueSeverity.ERROR
if state is DriveState.EXCEEDED
else ir.IssueSeverity.WARNING
),
translation_key=key,
translation_placeholders={
"total": str(drive.quota.total),
"used": str(drive.quota.used),
},
)
return drive

View File

@ -29,6 +29,16 @@
"default": "[%key:common::config_flow::create_entry::authenticated%]"
}
},
"issues": {
"drive_full": {
"title": "OneDrive data cap exceeded",
"description": "Your OneDrive has exceeded your quota limit. This means your next backup will fail. Please free up some space or upgrade your OneDrive plan. Currently using {used} GB of {total} GB."
},
"drive_almost_full": {
"title": "OneDrive near data cap",
"description": "Your OneDrive is near your quota limit. If you go over this limit your drive will be temporarily frozen and your backups will start failing. Please free up some space or upgrade your OneDrive plan. Currently using {used} GB of {total} GB."
}
},
"exceptions": {
"authentication_failed": {
"message": "Authentication failed"

View File

@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/opower",
"iot_class": "cloud_polling",
"loggers": ["opower"],
"requirements": ["opower==0.8.9"]
"requirements": ["opower==0.9.0"]
}

View File

@ -8,6 +8,6 @@
"iot_class": "local_polling",
"loggers": ["plugwise"],
"quality_scale": "platinum",
"requirements": ["plugwise==1.7.1"],
"requirements": ["plugwise==1.7.2"],
"zeroconf": ["_plugwise._tcp.local."]
}

View File

@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_IDLE, UnitOfDataRate
from homeassistant.const import STATE_IDLE, UnitOfDataRate, UnitOfInformation
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@ -27,8 +27,14 @@ from .coordinator import QBittorrentDataCoordinator
_LOGGER = logging.getLogger(__name__)
SENSOR_TYPE_CURRENT_STATUS = "current_status"
SENSOR_TYPE_CONNECTION_STATUS = "connection_status"
SENSOR_TYPE_DOWNLOAD_SPEED = "download_speed"
SENSOR_TYPE_UPLOAD_SPEED = "upload_speed"
SENSOR_TYPE_DOWNLOAD_SPEED_LIMIT = "download_speed_limit"
SENSOR_TYPE_UPLOAD_SPEED_LIMIT = "upload_speed_limit"
SENSOR_TYPE_ALLTIME_DOWNLOAD = "alltime_download"
SENSOR_TYPE_ALLTIME_UPLOAD = "alltime_upload"
SENSOR_TYPE_GLOBAL_RATIO = "global_ratio"
SENSOR_TYPE_ALL_TORRENTS = "all_torrents"
SENSOR_TYPE_PAUSED_TORRENTS = "paused_torrents"
SENSOR_TYPE_ACTIVE_TORRENTS = "active_torrents"
@ -50,18 +56,54 @@ def get_state(coordinator: QBittorrentDataCoordinator) -> str:
return STATE_IDLE
def get_dl(coordinator: QBittorrentDataCoordinator) -> int:
def get_connection_status(coordinator: QBittorrentDataCoordinator) -> str:
"""Get current download/upload state."""
server_state = cast(Mapping, coordinator.data.get("server_state"))
return cast(str, server_state.get("connection_status"))
def get_download_speed(coordinator: QBittorrentDataCoordinator) -> int:
"""Get current download speed."""
server_state = cast(Mapping, coordinator.data.get("server_state"))
return cast(int, server_state.get("dl_info_speed"))
def get_up(coordinator: QBittorrentDataCoordinator) -> int:
def get_upload_speed(coordinator: QBittorrentDataCoordinator) -> int:
"""Get current upload speed."""
server_state = cast(Mapping[str, Any], coordinator.data.get("server_state"))
return cast(int, server_state.get("up_info_speed"))
def get_download_speed_limit(coordinator: QBittorrentDataCoordinator) -> int:
"""Get current download speed."""
server_state = cast(Mapping, coordinator.data.get("server_state"))
return cast(int, server_state.get("dl_rate_limit"))
def get_upload_speed_limit(coordinator: QBittorrentDataCoordinator) -> int:
"""Get current upload speed."""
server_state = cast(Mapping[str, Any], coordinator.data.get("server_state"))
return cast(int, server_state.get("up_rate_limit"))
def get_alltime_download(coordinator: QBittorrentDataCoordinator) -> int:
"""Get current download speed."""
server_state = cast(Mapping, coordinator.data.get("server_state"))
return cast(int, server_state.get("alltime_dl"))
def get_alltime_upload(coordinator: QBittorrentDataCoordinator) -> int:
"""Get current download speed."""
server_state = cast(Mapping, coordinator.data.get("server_state"))
return cast(int, server_state.get("alltime_ul"))
def get_global_ratio(coordinator: QBittorrentDataCoordinator) -> float:
"""Get current download speed."""
server_state = cast(Mapping, coordinator.data.get("server_state"))
return cast(float, server_state.get("global_ratio"))
@dataclass(frozen=True, kw_only=True)
class QBittorrentSensorEntityDescription(SensorEntityDescription):
"""Entity description class for qBittorent sensors."""
@ -77,6 +119,13 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = (
options=[STATE_IDLE, STATE_UP_DOWN, STATE_SEEDING, STATE_DOWNLOADING],
value_fn=get_state,
),
QBittorrentSensorEntityDescription(
key=SENSOR_TYPE_CONNECTION_STATUS,
translation_key="connection_status",
device_class=SensorDeviceClass.ENUM,
options=["connected", "firewalled", "disconnected"],
value_fn=get_connection_status,
),
QBittorrentSensorEntityDescription(
key=SENSOR_TYPE_DOWNLOAD_SPEED,
translation_key="download_speed",
@ -85,7 +134,7 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND,
suggested_display_precision=2,
suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND,
value_fn=get_dl,
value_fn=get_download_speed,
),
QBittorrentSensorEntityDescription(
key=SENSOR_TYPE_UPLOAD_SPEED,
@ -95,7 +144,56 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND,
suggested_display_precision=2,
suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND,
value_fn=get_up,
value_fn=get_upload_speed,
),
QBittorrentSensorEntityDescription(
key=SENSOR_TYPE_DOWNLOAD_SPEED_LIMIT,
translation_key="download_speed_limit",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.DATA_RATE,
native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND,
suggested_display_precision=2,
suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND,
value_fn=get_download_speed_limit,
entity_registry_enabled_default=False,
),
QBittorrentSensorEntityDescription(
key=SENSOR_TYPE_UPLOAD_SPEED_LIMIT,
translation_key="upload_speed_limit",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.DATA_RATE,
native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND,
suggested_display_precision=2,
suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND,
value_fn=get_upload_speed_limit,
entity_registry_enabled_default=False,
),
QBittorrentSensorEntityDescription(
key=SENSOR_TYPE_ALLTIME_DOWNLOAD,
translation_key="alltime_download",
state_class=SensorStateClass.TOTAL_INCREASING,
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.BYTES,
suggested_display_precision=2,
suggested_unit_of_measurement=UnitOfInformation.TEBIBYTES,
value_fn=get_alltime_download,
),
QBittorrentSensorEntityDescription(
key=SENSOR_TYPE_ALLTIME_UPLOAD,
translation_key="alltime_upload",
state_class=SensorStateClass.TOTAL_INCREASING,
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement="B",
suggested_display_precision=2,
suggested_unit_of_measurement="TiB",
value_fn=get_alltime_upload,
),
QBittorrentSensorEntityDescription(
key=SENSOR_TYPE_GLOBAL_RATIO,
translation_key="global_ratio",
state_class=SensorStateClass.MEASUREMENT,
value_fn=get_global_ratio,
entity_registry_enabled_default=False,
),
QBittorrentSensorEntityDescription(
key=SENSOR_TYPE_ALL_TORRENTS,

View File

@ -26,6 +26,21 @@
"upload_speed": {
"name": "Upload speed"
},
"download_speed_limit": {
"name": "Download speed limit"
},
"upload_speed_limit": {
"name": "Upload speed limit"
},
"alltime_download": {
"name": "Alltime download"
},
"alltime_upload": {
"name": "Alltime upload"
},
"global_ratio": {
"name": "Global ratio"
},
"current_status": {
"name": "Status",
"state": {
@ -35,6 +50,14 @@
"downloading": "Downloading"
}
},
"connection_status": {
"name": "Connection status",
"state": {
"connected": "Conencted",
"firewalled": "Firewalled",
"disconnected": "Disconnected"
}
},
"active_torrents": {
"name": "Active torrents",
"unit_of_measurement": "torrents"

View File

@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pyseventeentrack"],
"requirements": ["pyseventeentrack==1.0.1"]
"requirements": ["pyseventeentrack==1.0.2"]
}

View File

@ -27,25 +27,25 @@
"services": {
"transition_on": {
"name": "Transition on",
"description": "Transitions to a target volume level over time.",
"description": "Transitions the volume level over a specified duration. If the device is powered off, the transition will start at the lowest volume level.",
"fields": {
"duration": {
"name": "Transition duration",
"description": "Time it takes to reach the target volume level."
"description": "Time to transition to the target volume."
},
"volume": {
"name": "Target volume",
"description": "If not specified, the volume level is read from the device."
"description": "Relative volume level. If not specified, the setting on the device is used."
}
}
},
"transition_off": {
"name": "Transition off",
"description": "Transitions volume off over time.",
"description": "Transitions the volume level to the lowest setting over a specified duration, then powers off the device.",
"fields": {
"duration": {
"name": "[%key:component::snooz::services::transition_on::fields::duration::name%]",
"description": "Time it takes to turn off."
"description": "Time to complete the transition."
}
}
}

View File

@ -7,7 +7,7 @@
"dependencies": ["ssdp"],
"documentation": "https://www.home-assistant.io/integrations/sonos",
"iot_class": "local_push",
"loggers": ["soco"],
"loggers": ["soco", "sonos_websocket"],
"requirements": ["soco==0.30.8", "sonos-websocket==0.1.3"],
"ssdp": [
{

View File

@ -12,5 +12,5 @@
"documentation": "https://www.home-assistant.io/integrations/squeezebox",
"iot_class": "local_polling",
"loggers": ["pysqueezebox"],
"requirements": ["pysqueezebox==0.11.1"]
"requirements": ["pysqueezebox==0.12.0"]
}

View File

@ -2,12 +2,10 @@
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .coordinator import StarlinkUpdateCoordinator
from .coordinator import StarlinkConfigEntry, StarlinkUpdateCoordinator
PLATFORMS = [
Platform.BINARY_SENSOR,
@ -19,21 +17,19 @@ PLATFORMS = [
]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(
hass: HomeAssistant, config_entry: StarlinkConfigEntry
) -> bool:
"""Set up Starlink from a config entry."""
coordinator = StarlinkUpdateCoordinator(hass, entry)
config_entry.runtime_data = StarlinkUpdateCoordinator(hass, config_entry)
await config_entry.runtime_data.async_config_entry_first_refresh()
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(
hass: HomeAssistant, config_entry: StarlinkConfigEntry
) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)

View File

@ -10,26 +10,22 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import StarlinkData
from .coordinator import StarlinkConfigEntry, StarlinkData
from .entity import StarlinkEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
config_entry: StarlinkConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up all binary sensors for this entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
StarlinkBinarySensorEntity(coordinator, description)
StarlinkBinarySensorEntity(config_entry.runtime_data, description)
for description in BINARY_SENSORS
)

View File

@ -10,26 +10,23 @@ from homeassistant.components.button import (
ButtonEntity,
ButtonEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import StarlinkUpdateCoordinator
from .coordinator import StarlinkConfigEntry, StarlinkUpdateCoordinator
from .entity import StarlinkEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
config_entry: StarlinkConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up all binary sensors for this entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
StarlinkButtonEntity(coordinator, description) for description in BUTTONS
StarlinkButtonEntity(config_entry.runtime_data, description)
for description in BUTTONS
)

View File

@ -34,6 +34,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
_LOGGER = logging.getLogger(__name__)
type StarlinkConfigEntry = ConfigEntry[StarlinkUpdateCoordinator]
@dataclass
class StarlinkData:
@ -51,9 +53,9 @@ class StarlinkData:
class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]):
"""Coordinates updates between all Starlink sensors defined in this file."""
config_entry: ConfigEntry
config_entry: StarlinkConfigEntry
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
def __init__(self, hass: HomeAssistant, config_entry: StarlinkConfigEntry) -> None:
"""Initialize an UpdateCoordinator for a group of sensors."""
self.channel_context = ChannelContext(target=config_entry.data[CONF_IP_ADDRESS])
self.history_stats_start = None

View File

@ -8,25 +8,22 @@ from homeassistant.components.device_tracker import (
TrackerEntity,
TrackerEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ATTR_ALTITUDE, DOMAIN
from .coordinator import StarlinkData
from .const import ATTR_ALTITUDE
from .coordinator import StarlinkConfigEntry, StarlinkData
from .entity import StarlinkEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
config_entry: StarlinkConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up all binary sensors for this entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
StarlinkDeviceTrackerEntity(coordinator, description)
StarlinkDeviceTrackerEntity(config_entry.runtime_data, description)
for description in DEVICE_TRACKERS
)

View File

@ -4,18 +4,15 @@ from dataclasses import asdict
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .coordinator import StarlinkUpdateCoordinator
from .coordinator import StarlinkConfigEntry
TO_REDACT = {"id", "latitude", "longitude", "altitude"}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
hass: HomeAssistant, config_entry: StarlinkConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for Starlink config entries."""
coordinator: StarlinkUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
return async_redact_data(asdict(coordinator.data), TO_REDACT)
return async_redact_data(asdict(config_entry.runtime_data.data), TO_REDACT)

View File

@ -12,7 +12,6 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
DEGREE,
PERCENTAGE,
@ -28,21 +27,19 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import now
from .const import DOMAIN
from .coordinator import StarlinkData
from .coordinator import StarlinkConfigEntry, StarlinkData
from .entity import StarlinkEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
config_entry: StarlinkConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up all sensors for this entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
StarlinkSensorEntity(coordinator, description) for description in SENSORS
StarlinkSensorEntity(config_entry.runtime_data, description)
for description in SENSORS
)

View File

@ -11,25 +11,22 @@ from homeassistant.components.switch import (
SwitchEntity,
SwitchEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import StarlinkData, StarlinkUpdateCoordinator
from .coordinator import StarlinkConfigEntry, StarlinkData, StarlinkUpdateCoordinator
from .entity import StarlinkEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
config_entry: StarlinkConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up all binary sensors for this entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
StarlinkSwitchEntity(coordinator, description) for description in SWITCHES
StarlinkSwitchEntity(config_entry.runtime_data, description)
for description in SWITCHES
)

View File

@ -8,26 +8,23 @@ from datetime import UTC, datetime, time, tzinfo
import math
from homeassistant.components.time import TimeEntity, TimeEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import StarlinkData, StarlinkUpdateCoordinator
from .coordinator import StarlinkConfigEntry, StarlinkData, StarlinkUpdateCoordinator
from .entity import StarlinkEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
config_entry: StarlinkConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up all time entities for this entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
StarlinkTimeEntity(coordinator, description) for description in TIMES
StarlinkTimeEntity(config_entry.runtime_data, description)
for description in TIMES
)

View File

@ -10,7 +10,7 @@ from synology_dsm.api.surveillance_station.camera import SynoCamera
from synology_dsm.exceptions import SynologyDSMNotLoggedInException
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_MAC, CONF_VERIFY_SSL
from homeassistant.const import CONF_MAC, CONF_SCAN_INTERVAL, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
@ -68,6 +68,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry,
options={**entry.options, CONF_BACKUP_SHARE: None, CONF_BACKUP_PATH: None},
)
if CONF_SCAN_INTERVAL in entry.options:
current_options = {**entry.options}
current_options.pop(CONF_SCAN_INTERVAL)
hass.config_entries.async_update_entry(entry, options=current_options)
# Continue setup
api = SynoApi(hass, entry)

View File

@ -33,14 +33,12 @@ from homeassistant.const import (
CONF_NAME,
CONF_PASSWORD,
CONF_PORT,
CONF_SCAN_INTERVAL,
CONF_SSL,
CONF_USERNAME,
CONF_VERIFY_SSL,
)
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
SelectOptionDict,
@ -67,7 +65,6 @@ from .const import (
DEFAULT_BACKUP_PATH,
DEFAULT_PORT,
DEFAULT_PORT_SSL,
DEFAULT_SCAN_INTERVAL,
DEFAULT_SNAPSHOT_QUALITY,
DEFAULT_TIMEOUT,
DEFAULT_USE_SSL,
@ -458,12 +455,6 @@ class SynologyDSMOptionsFlowHandler(OptionsFlow):
data_schema = vol.Schema(
{
vol.Required(
CONF_SCAN_INTERVAL,
default=self.config_entry.options.get(
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
),
): cv.positive_int,
vol.Required(
CONF_SNAPSHOT_QUALITY,
default=self.config_entry.options.get(

View File

@ -48,7 +48,6 @@ DEFAULT_VERIFY_SSL = False
DEFAULT_PORT = 5000
DEFAULT_PORT_SSL = 5001
# Options
DEFAULT_SCAN_INTERVAL = 15 # min
DEFAULT_TIMEOUT = ClientTimeout(total=60, connect=15)
DEFAULT_SNAPSHOT_QUALITY = SNAPSHOT_PROFILE_BALANCED
DEFAULT_BACKUP_PATH = "ha_backup"

View File

@ -14,14 +14,12 @@ from synology_dsm.exceptions import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_SCAN_INTERVAL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .common import SynoApi, raise_config_entry_auth_error
from .const import (
DEFAULT_SCAN_INTERVAL,
SIGNAL_CAMERA_SOURCE_CHANGED,
SYNOLOGY_AUTH_FAILED_EXCEPTIONS,
SYNOLOGY_CONNECTION_EXCEPTIONS,
@ -122,14 +120,7 @@ class SynologyDSMCentralUpdateCoordinator(SynologyDSMUpdateCoordinator[None]):
api: SynoApi,
) -> None:
"""Initialize DataUpdateCoordinator for central device."""
super().__init__(
hass,
entry,
api,
timedelta(
minutes=entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
),
)
super().__init__(hass, entry, api, timedelta(minutes=15))
@async_re_login_on_expired
async def _async_update_data(self) -> None:

View File

@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/synology_dsm",
"iot_class": "local_polling",
"loggers": ["synology_dsm"],
"requirements": ["py-synologydsm-api==2.6.2"],
"requirements": ["py-synologydsm-api==2.6.3"],
"ssdp": [
{
"manufacturer": "Synology",

View File

@ -68,8 +68,6 @@
"step": {
"init": {
"data": {
"scan_interval": "Minutes between scans",
"timeout": "Timeout (seconds)",
"snap_profile_type": "Quality level of camera snapshots (0:high 1:medium 2:low)",
"backup_share": "[%key:component::synology_dsm::config::step::backup_share::data::backup_share%]",
"backup_path": "[%key:component::synology_dsm::config::step::backup_share::data::backup_path%]"

View File

@ -17,7 +17,6 @@ from tesla_fleet_api.exceptions import (
TeslaFleetError,
VehicleOffline,
)
from tesla_fleet_api.ratecalculator import RateCalculator
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
@ -66,7 +65,6 @@ class TeslaFleetVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
updated_once: bool
pre2021: bool
last_active: datetime
rate: RateCalculator
def __init__(
self,
@ -87,44 +85,36 @@ class TeslaFleetVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.data = flatten(product)
self.updated_once = False
self.last_active = datetime.now()
self.rate = RateCalculator(100, 86400, VEHICLE_INTERVAL_SECONDS, 3600, 5)
async def _async_update_data(self) -> dict[str, Any]:
"""Update vehicle data using TeslaFleet API."""
try:
# Check if the vehicle is awake using a non-rate limited API call
if self.data["state"] != TeslaFleetState.ONLINE:
response = await self.api.vehicle()
self.data["state"] = response["response"]["state"]
# Check if the vehicle is awake using a free API call
response = await self.api.vehicle()
self.data["state"] = response["response"]["state"]
if self.data["state"] != TeslaFleetState.ONLINE:
return self.data
# This is a rated limited API call
self.rate.consume()
response = await self.api.vehicle_data(endpoints=ENDPOINTS)
data = response["response"]
except VehicleOffline:
self.data["state"] = TeslaFleetState.ASLEEP
return self.data
except RateLimited as e:
except RateLimited:
LOGGER.warning(
"%s rate limited, will retry in %s seconds",
"%s rate limited, will skip refresh",
self.name,
e.data.get("after"),
)
if "after" in e.data:
self.update_interval = timedelta(seconds=int(e.data["after"]))
return self.data
except (InvalidToken, OAuthExpired, LoginRequired) as e:
raise ConfigEntryAuthFailed from e
except TeslaFleetError as e:
raise UpdateFailed(e.message) from e
# Calculate ideal refresh interval
self.update_interval = timedelta(seconds=self.rate.calculate())
self.update_interval = VEHICLE_INTERVAL
self.updated_once = True

View File

@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/tesla_fleet",
"iot_class": "cloud_polling",
"loggers": ["tesla-fleet-api"],
"requirements": ["tesla-fleet-api==0.9.8"]
"requirements": ["tesla-fleet-api==0.9.10"]
}

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/teslemetry",
"iot_class": "cloud_polling",
"loggers": ["tesla-fleet-api"],
"requirements": ["tesla-fleet-api==0.9.8", "teslemetry-stream==0.6.10"]
"requirements": ["tesla-fleet-api==0.9.10", "teslemetry-stream==0.6.10"]
}

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/tessie",
"iot_class": "cloud_polling",
"loggers": ["tessie", "tesla-fleet-api"],
"requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.8"]
"requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.10"]
}

View File

@ -20,6 +20,9 @@ CAMERAS: tuple[str, ...] = (
# Smart Camera (including doorbells)
# https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu
"sp",
# Smart Camera - Low power consumption camera
# Undocumented, see https://github.com/home-assistant/core/issues/132844
"dghsxj",
)

View File

@ -261,6 +261,20 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
# Smart Camera - Low power consumption camera
# Undocumented, see https://github.com/home-assistant/core/issues/132844
"dghsxj": (
TuyaLightEntityDescription(
key=DPCode.FLOODLIGHT_SWITCH,
brightness=DPCode.FLOODLIGHT_LIGHTNESS,
name="Floodlight",
),
TuyaLightEntityDescription(
key=DPCode.BASIC_INDICATOR,
name="Indicator light",
entity_category=EntityCategory.CONFIG,
),
),
# Smart Gardening system
# https://developer.tuya.com/en/docs/iot/categorysz?id=Kaiuz4e6h7up0
"sz": (

View File

@ -174,6 +174,15 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
# Smart Camera - Low power consumption camera
# Undocumented, see https://github.com/home-assistant/core/issues/132844
"dghsxj": (
NumberEntityDescription(
key=DPCode.BASIC_DEVICE_VOLUME,
translation_key="volume",
entity_category=EntityCategory.CONFIG,
),
),
# Dimmer Switch
# https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o
"tgkg": (

View File

@ -128,6 +128,40 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = {
translation_key="motion_sensitivity",
),
),
# Smart Camera - Low power consumption camera
# Undocumented, see https://github.com/home-assistant/core/issues/132844
"dghsxj": (
SelectEntityDescription(
key=DPCode.IPC_WORK_MODE,
entity_category=EntityCategory.CONFIG,
translation_key="ipc_work_mode",
),
SelectEntityDescription(
key=DPCode.DECIBEL_SENSITIVITY,
entity_category=EntityCategory.CONFIG,
translation_key="decibel_sensitivity",
),
SelectEntityDescription(
key=DPCode.RECORD_MODE,
entity_category=EntityCategory.CONFIG,
translation_key="record_mode",
),
SelectEntityDescription(
key=DPCode.BASIC_NIGHTVISION,
entity_category=EntityCategory.CONFIG,
translation_key="basic_nightvision",
),
SelectEntityDescription(
key=DPCode.BASIC_ANTI_FLICKER,
entity_category=EntityCategory.CONFIG,
translation_key="basic_anti_flicker",
),
SelectEntityDescription(
key=DPCode.MOTION_SENSITIVITY,
entity_category=EntityCategory.CONFIG,
translation_key="motion_sensitivity",
),
),
# IoT Switch?
# Note: Undocumented
"tdq": (

View File

@ -632,6 +632,29 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
state_class=SensorStateClass.MEASUREMENT,
),
),
# Smart Camera - Low power consumption camera
# Undocumented, see https://github.com/home-assistant/core/issues/132844
"dghsxj": (
TuyaSensorEntityDescription(
key=DPCode.SENSOR_TEMPERATURE,
translation_key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
TuyaSensorEntityDescription(
key=DPCode.SENSOR_HUMIDITY,
translation_key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
),
TuyaSensorEntityDescription(
key=DPCode.WIRELESS_ELECTRICITY,
translation_key="battery",
device_class=SensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
),
# Fingerbot
"szjqr": BATTERY_SENSORS,
# Solar Light

View File

@ -44,6 +44,13 @@ SIRENS: dict[str, tuple[SirenEntityDescription, ...]] = {
key=DPCode.SIREN_SWITCH,
),
),
# Smart Camera - Low power consumption camera
# Undocumented, see https://github.com/home-assistant/core/issues/132844
"dghsxj": (
SirenEntityDescription(
key=DPCode.SIREN_SWITCH,
),
),
# CO2 Detector
# https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy
"co2bj": (

View File

@ -509,6 +509,65 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
# Smart Camera - Low power consumption camera
# Undocumented, see https://github.com/home-assistant/core/issues/132844
"dghsxj": (
SwitchEntityDescription(
key=DPCode.WIRELESS_BATTERYLOCK,
translation_key="battery_lock",
entity_category=EntityCategory.CONFIG,
),
SwitchEntityDescription(
key=DPCode.CRY_DETECTION_SWITCH,
translation_key="cry_detection",
entity_category=EntityCategory.CONFIG,
),
SwitchEntityDescription(
key=DPCode.DECIBEL_SWITCH,
translation_key="sound_detection",
entity_category=EntityCategory.CONFIG,
),
SwitchEntityDescription(
key=DPCode.RECORD_SWITCH,
translation_key="video_recording",
entity_category=EntityCategory.CONFIG,
),
SwitchEntityDescription(
key=DPCode.MOTION_RECORD,
translation_key="motion_recording",
entity_category=EntityCategory.CONFIG,
),
SwitchEntityDescription(
key=DPCode.BASIC_PRIVATE,
translation_key="privacy_mode",
entity_category=EntityCategory.CONFIG,
),
SwitchEntityDescription(
key=DPCode.BASIC_FLIP,
translation_key="flip",
entity_category=EntityCategory.CONFIG,
),
SwitchEntityDescription(
key=DPCode.BASIC_OSD,
translation_key="time_watermark",
entity_category=EntityCategory.CONFIG,
),
SwitchEntityDescription(
key=DPCode.BASIC_WDR,
translation_key="wide_dynamic_range",
entity_category=EntityCategory.CONFIG,
),
SwitchEntityDescription(
key=DPCode.MOTION_TRACKING,
translation_key="motion_tracking",
entity_category=EntityCategory.CONFIG,
),
SwitchEntityDescription(
key=DPCode.MOTION_SWITCH,
translation_key="motion_alarm",
entity_category=EntityCategory.CONFIG,
),
),
# Smart Gardening system
# https://developer.tuya.com/en/docs/iot/categorysz?id=Kaiuz4e6h7up0
"sz": (

View File

@ -27,6 +27,7 @@ PLATFORMS = [
Platform.HUMIDIFIER,
Platform.LIGHT,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
]

View File

@ -29,6 +29,10 @@ VS_HUMIDIFIER_MODE_HUMIDITY = "humidity"
VS_HUMIDIFIER_MODE_MANUAL = "manual"
VS_HUMIDIFIER_MODE_SLEEP = "sleep"
NIGHT_LIGHT_LEVEL_BRIGHT = "bright"
NIGHT_LIGHT_LEVEL_DIM = "dim"
NIGHT_LIGHT_LEVEL_OFF = "off"
VeSyncHumidifierDevice = VeSyncHumid200300S | VeSyncSuperior6000S
"""Humidifier device types"""

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