diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e347b21b922049..789e1ed6079b8e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -87,7 +87,7 @@ repos: pass_filenames: false language: script types: [text] - files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/.+/(quality_scale)\.yaml|homeassistant/brands/.*\.json|homeassistant/.+/services\.yaml|script/hassfest/(?!metadata|mypy_config).+\.py|requirements.+\.txt)$ + files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/.+/(conditions|quality_scale|services|triggers)\.yaml|homeassistant/brands/.*\.json|script/hassfest/(?!metadata|mypy_config).+\.py|requirements.+\.txt)$ - id: hassfest-metadata name: hassfest-metadata entry: script/run-in-env.sh python3 -m script.hassfest -p metadata,docker diff --git a/.strict-typing b/.strict-typing index 06b4fa56fb513a..062aafc709c29b 100644 --- a/.strict-typing +++ b/.strict-typing @@ -579,6 +579,7 @@ homeassistant.components.wiz.* homeassistant.components.wled.* homeassistant.components.workday.* homeassistant.components.worldclock.* +homeassistant.components.xbox.* homeassistant.components.xiaomi_ble.* homeassistant.components.yale_smart_alarm.* homeassistant.components.yalexs_ble.* diff --git a/homeassistant/components/icloud/account.py b/homeassistant/components/icloud/account.py index 35e04d20ecd155..d1d35def76bd29 100644 --- a/homeassistant/components/icloud/account.py +++ b/homeassistant/components/icloud/account.py @@ -12,6 +12,7 @@ PyiCloudFailedLoginException, PyiCloudNoDevicesException, PyiCloudServiceNotActivatedException, + PyiCloudServiceUnavailable, ) from pyicloud.services.findmyiphone import AppleDevice @@ -130,15 +131,21 @@ def setup(self) -> None: except ( PyiCloudServiceNotActivatedException, PyiCloudNoDevicesException, + PyiCloudServiceUnavailable, ) as err: _LOGGER.error("No iCloud device found") raise ConfigEntryNotReady from err - self._owner_fullname = f"{user_info['firstName']} {user_info['lastName']}" + if user_info is None: + raise ConfigEntryNotReady("No user info found in iCloud devices response") + + self._owner_fullname = ( + f"{user_info.get('firstName')} {user_info.get('lastName')}" + ) self._family_members_fullname = {} if user_info.get("membersInfo") is not None: - for prs_id, member in user_info["membersInfo"].items(): + for prs_id, member in user_info.get("membersInfo").items(): self._family_members_fullname[prs_id] = ( f"{member['firstName']} {member['lastName']}" ) diff --git a/homeassistant/components/icloud/manifest.json b/homeassistant/components/icloud/manifest.json index 60a4063a079fad..0cf6b89d20cdac 100644 --- a/homeassistant/components/icloud/manifest.json +++ b/homeassistant/components/icloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/icloud", "iot_class": "cloud_polling", "loggers": ["keyrings.alt", "pyicloud"], - "requirements": ["pyicloud==2.1.0"] + "requirements": ["pyicloud==2.2.0"] } diff --git a/homeassistant/components/kostal_plenticore/coordinator.py b/homeassistant/components/kostal_plenticore/coordinator.py index d312130bb54864..c3a49359fc4eb0 100644 --- a/homeassistant/components/kostal_plenticore/coordinator.py +++ b/homeassistant/components/kostal_plenticore/coordinator.py @@ -237,14 +237,23 @@ class SettingDataUpdateCoordinator( """Implementation of PlenticoreUpdateCoordinator for settings data.""" async def _async_update_data(self) -> Mapping[str, Mapping[str, str]]: - client = self._plenticore.client + if (client := self._plenticore.client) is None: + return {} - if not self._fetch or client is None: + fetch = defaultdict(set) + + for module_id, data_ids in self._fetch.items(): + fetch[module_id].update(data_ids) + + for module_id, data_id in self.async_contexts(): + fetch[module_id].add(data_id) + + if not fetch: return {} - _LOGGER.debug("Fetching %s for %s", self.name, self._fetch) + _LOGGER.debug("Fetching %s for %s", self.name, fetch) - return await client.get_setting_values(self._fetch) + return await client.get_setting_values(fetch) class PlenticoreSelectUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): diff --git a/homeassistant/components/kostal_plenticore/diagnostics.py b/homeassistant/components/kostal_plenticore/diagnostics.py index 4d4d61f56a734d..a583770379cfa9 100644 --- a/homeassistant/components/kostal_plenticore/diagnostics.py +++ b/homeassistant/components/kostal_plenticore/diagnostics.py @@ -34,6 +34,29 @@ async def async_get_config_entry_diagnostics( }, } + # Add important information how the inverter is configured + string_count_setting = await plenticore.client.get_setting_values( + "devices:local", "Properties:StringCnt" + ) + try: + string_count = int( + string_count_setting["devices:local"]["Properties:StringCnt"] + ) + except ValueError: + string_count = 0 + + configuration_settings = await plenticore.client.get_setting_values( + "devices:local", + ( + "Properties:StringCnt", + *(f"Properties:String{idx}Features" for idx in range(string_count)), + ), + ) + + data["configuration"] = { + **configuration_settings, + } + device_info = {**plenticore.device_info} device_info[ATTR_IDENTIFIERS] = REDACTED # contains serial number data["device"] = device_info diff --git a/homeassistant/components/kostal_plenticore/switch.py b/homeassistant/components/kostal_plenticore/switch.py index feeb4bc5bb550a..85a5cdf8fe73a2 100644 --- a/homeassistant/components/kostal_plenticore/switch.py +++ b/homeassistant/components/kostal_plenticore/switch.py @@ -5,12 +5,13 @@ from dataclasses import dataclass from datetime import timedelta import logging -from typing import Any +from typing import Any, Final from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -66,7 +67,7 @@ async def async_setup_entry( """Add kostal plenticore Switch.""" plenticore = entry.runtime_data - entities = [] + entities: list[Entity] = [] available_settings_data = await plenticore.client.get_settings() settings_data_update_coordinator = SettingDataUpdateCoordinator( @@ -103,6 +104,57 @@ async def async_setup_entry( ) ) + # add shadow management switches for strings which support it + string_count_setting = await plenticore.client.get_setting_values( + "devices:local", "Properties:StringCnt" + ) + try: + string_count = int( + string_count_setting["devices:local"]["Properties:StringCnt"] + ) + except ValueError: + string_count = 0 + + dc_strings = tuple(range(string_count)) + dc_string_feature_ids = tuple( + PlenticoreShadowMgmtSwitch.DC_STRING_FEATURE_DATA_ID % dc_string + for dc_string in dc_strings + ) + + dc_string_features = await plenticore.client.get_setting_values( + PlenticoreShadowMgmtSwitch.MODULE_ID, + dc_string_feature_ids, + ) + + for dc_string, dc_string_feature_id in zip( + dc_strings, dc_string_feature_ids, strict=True + ): + try: + dc_string_feature = int( + dc_string_features[PlenticoreShadowMgmtSwitch.MODULE_ID][ + dc_string_feature_id + ] + ) + except ValueError: + dc_string_feature = 0 + + if dc_string_feature == PlenticoreShadowMgmtSwitch.SHADOW_MANAGEMENT_SUPPORT: + entities.append( + PlenticoreShadowMgmtSwitch( + settings_data_update_coordinator, + dc_string, + entry.entry_id, + entry.title, + plenticore.device_info, + ) + ) + else: + _LOGGER.debug( + "Skipping shadow management for DC string %d, not supported (Feature: %d)", + dc_string + 1, + dc_string_feature, + ) + async_add_entities(entities) @@ -136,7 +188,6 @@ def __init__( self.off_value = description.off_value self.off_label = description.off_label self._attr_unique_id = f"{entry_id}_{description.module_id}_{description.key}" - self._attr_device_info = device_info @property @@ -189,3 +240,98 @@ def is_on(self) -> bool: f"{self.platform_name} {self._name} {self.off_label}" ) return bool(self.coordinator.data[self.module_id][self.data_id] == self._is_on) + + +class PlenticoreShadowMgmtSwitch( + CoordinatorEntity[SettingDataUpdateCoordinator], SwitchEntity +): + """Representation of a Plenticore Switch for shadow management. + + The shadow management switch can be controlled for each DC string separately. The DC string is + coded as bit in a single settings value, bit 0 for DC string 1, bit 1 for DC string 2, etc. + + Not all DC strings are available for shadown management, for example if one of them is used + for a battery. + """ + + _attr_entity_category = EntityCategory.CONFIG + entity_description: SwitchEntityDescription + + MODULE_ID: Final = "devices:local" + + SHADOW_DATA_ID: Final = "Generator:ShadowMgmt:Enable" + """Settings id for the bit coded shadow management.""" + + DC_STRING_FEATURE_DATA_ID: Final = "Properties:String%dFeatures" + """Settings id pattern for the DC string features.""" + + SHADOW_MANAGEMENT_SUPPORT: Final = 1 + """Feature value for shadow management support in the DC string features.""" + + def __init__( + self, + coordinator: SettingDataUpdateCoordinator, + dc_string: int, + entry_id: str, + platform_name: str, + device_info: DeviceInfo, + ) -> None: + """Create a new Switch Entity for Plenticore shadow management.""" + super().__init__(coordinator, context=(self.MODULE_ID, self.SHADOW_DATA_ID)) + + self._mask: Final = 1 << dc_string + + self.entity_description = SwitchEntityDescription( + key=f"ShadowMgmt{dc_string}", + name=f"Shadow Management DC string {dc_string + 1}", + entity_registry_enabled_default=False, + ) + + self.platform_name = platform_name + self._attr_name = f"{platform_name} {self.entity_description.name}" + self._attr_unique_id = ( + f"{entry_id}_{self.MODULE_ID}_{self.SHADOW_DATA_ID}_{dc_string}" + ) + self._attr_device_info = device_info + + @property + def available(self) -> bool: + """Return if entity is available.""" + return ( + super().available + and self.coordinator.data is not None + and self.MODULE_ID in self.coordinator.data + and self.SHADOW_DATA_ID in self.coordinator.data[self.MODULE_ID] + ) + + def _get_shadow_mgmt_value(self) -> int: + """Return the current shadow management value for all strings as integer.""" + try: + return int(self.coordinator.data[self.MODULE_ID][self.SHADOW_DATA_ID]) + except ValueError: + return 0 + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn shadow management on.""" + shadow_mgmt_value = self._get_shadow_mgmt_value() + shadow_mgmt_value |= self._mask + + if await self.coordinator.async_write_data( + self.MODULE_ID, {self.SHADOW_DATA_ID: str(shadow_mgmt_value)} + ): + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn shadow management off.""" + shadow_mgmt_value = self._get_shadow_mgmt_value() + shadow_mgmt_value &= ~self._mask + + if await self.coordinator.async_write_data( + self.MODULE_ID, {self.SHADOW_DATA_ID: str(shadow_mgmt_value)} + ): + await self.coordinator.async_request_refresh() + + @property + def is_on(self) -> bool: + """Return true if shadow management is on.""" + return (self._get_shadow_mgmt_value() & self._mask) != 0 diff --git a/homeassistant/components/lcn/binary_sensor.py b/homeassistant/components/lcn/binary_sensor.py index a9f194fe1b82da..9c7b2058a4f9dd 100644 --- a/homeassistant/components/lcn/binary_sensor.py +++ b/homeassistant/components/lcn/binary_sensor.py @@ -1,6 +1,7 @@ """Support for LCN binary sensors.""" from collections.abc import Iterable +from datetime import timedelta from functools import partial import pypck @@ -19,6 +20,7 @@ from .helpers import InputType, LcnConfigEntry PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(minutes=1) def add_lcn_entities( @@ -69,21 +71,11 @@ def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: config[CONF_DOMAIN_DATA][CONF_SOURCE] ] - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - await super().async_added_to_hass() - if not self.device_connection.is_group: - await self.device_connection.activate_status_request_handler( - self.bin_sensor_port - ) - - async def async_will_remove_from_hass(self) -> None: - """Run when entity will be removed from hass.""" - await super().async_will_remove_from_hass() - if not self.device_connection.is_group: - await self.device_connection.cancel_status_request_handler( - self.bin_sensor_port - ) + async def async_update(self) -> None: + """Update the state of the entity.""" + await self.device_connection.request_status_binary_sensors( + SCAN_INTERVAL.seconds + ) def input_received(self, input_obj: InputType) -> None: """Set sensor value when LCN input object (command) is received.""" diff --git a/homeassistant/components/lcn/climate.py b/homeassistant/components/lcn/climate.py index 5dc1419cecc621..53eb86e01273b0 100644 --- a/homeassistant/components/lcn/climate.py +++ b/homeassistant/components/lcn/climate.py @@ -1,6 +1,8 @@ """Support for LCN climate control.""" +import asyncio from collections.abc import Iterable +from datetime import timedelta from functools import partial from typing import Any, cast @@ -36,6 +38,7 @@ from .helpers import InputType, LcnConfigEntry PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(minutes=1) def add_lcn_entities( @@ -110,20 +113,6 @@ def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - await super().async_added_to_hass() - if not self.device_connection.is_group: - await self.device_connection.activate_status_request_handler(self.variable) - await self.device_connection.activate_status_request_handler(self.setpoint) - - async def async_will_remove_from_hass(self) -> None: - """Run when entity will be removed from hass.""" - await super().async_will_remove_from_hass() - if not self.device_connection.is_group: - await self.device_connection.cancel_status_request_handler(self.variable) - await self.device_connection.cancel_status_request_handler(self.setpoint) - @property def temperature_unit(self) -> str: """Return the unit of measurement.""" @@ -192,6 +181,17 @@ async def async_set_temperature(self, **kwargs: Any) -> None: self._target_temperature = temperature self.async_write_ha_state() + async def async_update(self) -> None: + """Update the state of the entity.""" + await asyncio.gather( + self.device_connection.request_status_variable( + self.variable, SCAN_INTERVAL.seconds + ), + self.device_connection.request_status_variable( + self.setpoint, SCAN_INTERVAL.seconds + ), + ) + def input_received(self, input_obj: InputType) -> None: """Set temperature value when LCN input object is received.""" if not isinstance(input_obj, pypck.inputs.ModStatusVar): diff --git a/homeassistant/components/lcn/cover.py b/homeassistant/components/lcn/cover.py index cb292f7cadfe89..951bb353bfcf6b 100644 --- a/homeassistant/components/lcn/cover.py +++ b/homeassistant/components/lcn/cover.py @@ -1,6 +1,8 @@ """Support for LCN covers.""" +import asyncio from collections.abc import Iterable +from datetime import timedelta from functools import partial from typing import Any @@ -27,6 +29,7 @@ from .helpers import InputType, LcnConfigEntry PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(minutes=1) def add_lcn_entities( @@ -73,7 +76,7 @@ async def async_setup_entry( class LcnOutputsCover(LcnEntity, CoverEntity): """Representation of a LCN cover connected to output ports.""" - _attr_is_closed = False + _attr_is_closed = True _attr_is_closing = False _attr_is_opening = False _attr_assumed_state = True @@ -93,28 +96,6 @@ def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: else: self.reverse_time = None - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - await super().async_added_to_hass() - if not self.device_connection.is_group: - await self.device_connection.activate_status_request_handler( - pypck.lcn_defs.OutputPort["OUTPUTUP"] - ) - await self.device_connection.activate_status_request_handler( - pypck.lcn_defs.OutputPort["OUTPUTDOWN"] - ) - - async def async_will_remove_from_hass(self) -> None: - """Run when entity will be removed from hass.""" - await super().async_will_remove_from_hass() - if not self.device_connection.is_group: - await self.device_connection.cancel_status_request_handler( - pypck.lcn_defs.OutputPort["OUTPUTUP"] - ) - await self.device_connection.cancel_status_request_handler( - pypck.lcn_defs.OutputPort["OUTPUTDOWN"] - ) - async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" state = pypck.lcn_defs.MotorStateModifier.DOWN @@ -147,6 +128,18 @@ async def async_stop_cover(self, **kwargs: Any) -> None: self._attr_is_opening = False self.async_write_ha_state() + async def async_update(self) -> None: + """Update the state of the entity.""" + if not self.device_connection.is_group: + await asyncio.gather( + self.device_connection.request_status_output( + pypck.lcn_defs.OutputPort["OUTPUTUP"], SCAN_INTERVAL.seconds + ), + self.device_connection.request_status_output( + pypck.lcn_defs.OutputPort["OUTPUTDOWN"], SCAN_INTERVAL.seconds + ), + ) + def input_received(self, input_obj: InputType) -> None: """Set cover states when LCN input object (command) is received.""" if ( @@ -175,7 +168,7 @@ def input_received(self, input_obj: InputType) -> None: class LcnRelayCover(LcnEntity, CoverEntity): """Representation of a LCN cover connected to relays.""" - _attr_is_closed = False + _attr_is_closed = True _attr_is_closing = False _attr_is_opening = False _attr_assumed_state = True @@ -206,20 +199,6 @@ def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: self._is_closing = False self._is_opening = False - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - await super().async_added_to_hass() - if not self.device_connection.is_group: - await self.device_connection.activate_status_request_handler( - self.motor, self.positioning_mode - ) - - async def async_will_remove_from_hass(self) -> None: - """Run when entity will be removed from hass.""" - await super().async_will_remove_from_hass() - if not self.device_connection.is_group: - await self.device_connection.cancel_status_request_handler(self.motor) - async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" if not await self.device_connection.control_motor_relays( @@ -274,6 +253,17 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: self.async_write_ha_state() + async def async_update(self) -> None: + """Update the state of the entity.""" + coros = [self.device_connection.request_status_relays(SCAN_INTERVAL.seconds)] + if self.positioning_mode == pypck.lcn_defs.MotorPositioningMode.BS4: + coros.append( + self.device_connection.request_status_motor_position( + self.motor, self.positioning_mode, SCAN_INTERVAL.seconds + ) + ) + await asyncio.gather(*coros) + def input_received(self, input_obj: InputType) -> None: """Set cover states when LCN input object (command) is received.""" if isinstance(input_obj, pypck.inputs.ModStatusRelays): diff --git a/homeassistant/components/lcn/entity.py b/homeassistant/components/lcn/entity.py index f94251983b4feb..aab9ad7ca88e78 100644 --- a/homeassistant/components/lcn/entity.py +++ b/homeassistant/components/lcn/entity.py @@ -22,7 +22,6 @@ class LcnEntity(Entity): """Parent class for all entities associated with the LCN component.""" - _attr_should_poll = False _attr_has_entity_name = True device_connection: DeviceConnectionType @@ -57,15 +56,24 @@ def unique_id(self) -> str: ).lower(), ) + @property + def should_poll(self) -> bool: + """Groups may not poll for a status.""" + return not self.device_connection.is_group + async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" self.device_connection = get_device_connection( self.hass, self.config[CONF_ADDRESS], self.config_entry ) - if not self.device_connection.is_group: - self._unregister_for_inputs = self.device_connection.register_for_inputs( - self.input_received - ) + if self.device_connection.is_group: + return + + self._unregister_for_inputs = self.device_connection.register_for_inputs( + self.input_received + ) + + self.schedule_update_ha_state(force_refresh=True) async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index 4937b5dbca744b..feeaec6268a736 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -251,13 +251,19 @@ async def async_update_device_config( """Fill missing values in device_config with infos from LCN bus.""" # fetch serial info if device is module if not (is_group := device_config[CONF_ADDRESS][2]): # is module - await device_connection.serial_known + await device_connection.serials_known() if device_config[CONF_HARDWARE_SERIAL] == -1: - device_config[CONF_HARDWARE_SERIAL] = device_connection.hardware_serial + device_config[CONF_HARDWARE_SERIAL] = ( + device_connection.serials.hardware_serial + ) if device_config[CONF_SOFTWARE_SERIAL] == -1: - device_config[CONF_SOFTWARE_SERIAL] = device_connection.software_serial + device_config[CONF_SOFTWARE_SERIAL] = ( + device_connection.serials.software_serial + ) if device_config[CONF_HARDWARE_TYPE] == -1: - device_config[CONF_HARDWARE_TYPE] = device_connection.hardware_type.value + device_config[CONF_HARDWARE_TYPE] = ( + device_connection.serials.hardware_type.value + ) # fetch name if device is module if device_config[CONF_NAME] != "": diff --git a/homeassistant/components/lcn/light.py b/homeassistant/components/lcn/light.py index b9dad0aeb199aa..e690bb420d1bb4 100644 --- a/homeassistant/components/lcn/light.py +++ b/homeassistant/components/lcn/light.py @@ -1,6 +1,7 @@ """Support for LCN lights.""" from collections.abc import Iterable +from datetime import timedelta from functools import partial from typing import Any @@ -33,6 +34,7 @@ BRIGHTNESS_SCALE = (1, 100) PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(minutes=1) def add_lcn_entities( @@ -100,18 +102,6 @@ def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: self._attr_color_mode = ColorMode.ONOFF self._attr_supported_color_modes = {self._attr_color_mode} - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - await super().async_added_to_hass() - if not self.device_connection.is_group: - await self.device_connection.activate_status_request_handler(self.output) - - async def async_will_remove_from_hass(self) -> None: - """Run when entity will be removed from hass.""" - await super().async_will_remove_from_hass() - if not self.device_connection.is_group: - await self.device_connection.cancel_status_request_handler(self.output) - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" if ATTR_TRANSITION in kwargs: @@ -157,6 +147,12 @@ async def async_turn_off(self, **kwargs: Any) -> None: self._attr_is_on = False self.async_write_ha_state() + async def async_update(self) -> None: + """Update the state of the entity.""" + await self.device_connection.request_status_output( + self.output, SCAN_INTERVAL.seconds + ) + def input_received(self, input_obj: InputType) -> None: """Set light state when LCN input object (command) is received.""" if ( @@ -184,18 +180,6 @@ def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: self.output = pypck.lcn_defs.RelayPort[config[CONF_DOMAIN_DATA][CONF_OUTPUT]] - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - await super().async_added_to_hass() - if not self.device_connection.is_group: - await self.device_connection.activate_status_request_handler(self.output) - - async def async_will_remove_from_hass(self) -> None: - """Run when entity will be removed from hass.""" - await super().async_will_remove_from_hass() - if not self.device_connection.is_group: - await self.device_connection.cancel_status_request_handler(self.output) - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" states = [pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8 @@ -214,6 +198,10 @@ async def async_turn_off(self, **kwargs: Any) -> None: self._attr_is_on = False self.async_write_ha_state() + async def async_update(self) -> None: + """Update the state of the entity.""" + await self.device_connection.request_status_relays(SCAN_INTERVAL.seconds) + def input_received(self, input_obj: InputType) -> None: """Set light state when LCN input object (command) is received.""" if not isinstance(input_obj, pypck.inputs.ModStatusRelays): diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index a08ee0d8880556..8c5da184b52284 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -6,8 +6,8 @@ "config_flow": true, "dependencies": ["http", "websocket_api"], "documentation": "https://www.home-assistant.io/integrations/lcn", - "iot_class": "local_push", + "iot_class": "local_polling", "loggers": ["pypck"], "quality_scale": "bronze", - "requirements": ["pypck==0.8.12", "lcn-frontend==0.2.7"] + "requirements": ["pypck==0.9.2", "lcn-frontend==0.2.7"] } diff --git a/homeassistant/components/lcn/sensor.py b/homeassistant/components/lcn/sensor.py index 3d3f946b3aacc0..d9d76cd17920df 100644 --- a/homeassistant/components/lcn/sensor.py +++ b/homeassistant/components/lcn/sensor.py @@ -1,6 +1,7 @@ """Support for LCN sensors.""" from collections.abc import Iterable +from datetime import timedelta from functools import partial from itertools import chain @@ -40,6 +41,8 @@ from .helpers import InputType, LcnConfigEntry PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(minutes=1) + DEVICE_CLASS_MAPPING = { pypck.lcn_defs.VarUnit.CELSIUS: SensorDeviceClass.TEMPERATURE, @@ -128,17 +131,11 @@ def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: ) self._attr_device_class = DEVICE_CLASS_MAPPING.get(self.unit) - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - await super().async_added_to_hass() - if not self.device_connection.is_group: - await self.device_connection.activate_status_request_handler(self.variable) - - async def async_will_remove_from_hass(self) -> None: - """Run when entity will be removed from hass.""" - await super().async_will_remove_from_hass() - if not self.device_connection.is_group: - await self.device_connection.cancel_status_request_handler(self.variable) + async def async_update(self) -> None: + """Update the state of the entity.""" + await self.device_connection.request_status_variable( + self.variable, SCAN_INTERVAL.seconds + ) def input_received(self, input_obj: InputType) -> None: """Set sensor value when LCN input object (command) is received.""" @@ -170,17 +167,11 @@ def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: config[CONF_DOMAIN_DATA][CONF_SOURCE] ] - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - await super().async_added_to_hass() - if not self.device_connection.is_group: - await self.device_connection.activate_status_request_handler(self.source) - - async def async_will_remove_from_hass(self) -> None: - """Run when entity will be removed from hass.""" - await super().async_will_remove_from_hass() - if not self.device_connection.is_group: - await self.device_connection.cancel_status_request_handler(self.source) + async def async_update(self) -> None: + """Update the state of the entity.""" + await self.device_connection.request_status_led_and_logic_ops( + SCAN_INTERVAL.seconds + ) def input_received(self, input_obj: InputType) -> None: """Set sensor value when LCN input object (command) is received.""" diff --git a/homeassistant/components/lcn/services.py b/homeassistant/components/lcn/services.py index 8a172ccac2e3a0..738397e8cb5a54 100644 --- a/homeassistant/components/lcn/services.py +++ b/homeassistant/components/lcn/services.py @@ -380,9 +380,6 @@ async def async_call_service(self, service: ServiceCall) -> None: else: await device_connection.lock_keys(table_id, states) - handler = device_connection.status_requests_handler - await handler.request_status_locked_keys_timeout() - class DynText(LcnServiceCall): """Send dynamic text to LCN-GTxD displays.""" diff --git a/homeassistant/components/lcn/switch.py b/homeassistant/components/lcn/switch.py index f0bb432fef9fb9..0b4156550b8253 100644 --- a/homeassistant/components/lcn/switch.py +++ b/homeassistant/components/lcn/switch.py @@ -1,6 +1,7 @@ """Support for LCN switches.""" from collections.abc import Iterable +from datetime import timedelta from functools import partial from typing import Any @@ -17,6 +18,7 @@ from .helpers import InputType, LcnConfigEntry PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(minutes=1) def add_lcn_switch_entities( @@ -77,18 +79,6 @@ def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: self.output = pypck.lcn_defs.OutputPort[config[CONF_DOMAIN_DATA][CONF_OUTPUT]] - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - await super().async_added_to_hass() - if not self.device_connection.is_group: - await self.device_connection.activate_status_request_handler(self.output) - - async def async_will_remove_from_hass(self) -> None: - """Run when entity will be removed from hass.""" - await super().async_will_remove_from_hass() - if not self.device_connection.is_group: - await self.device_connection.cancel_status_request_handler(self.output) - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" if not await self.device_connection.dim_output(self.output.value, 100, 0): @@ -103,6 +93,12 @@ async def async_turn_off(self, **kwargs: Any) -> None: self._attr_is_on = False self.async_write_ha_state() + async def async_update(self) -> None: + """Update the state of the entity.""" + await self.device_connection.request_status_output( + self.output, SCAN_INTERVAL.seconds + ) + def input_received(self, input_obj: InputType) -> None: """Set switch state when LCN input object (command) is received.""" if ( @@ -126,18 +122,6 @@ def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: self.output = pypck.lcn_defs.RelayPort[config[CONF_DOMAIN_DATA][CONF_OUTPUT]] - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - await super().async_added_to_hass() - if not self.device_connection.is_group: - await self.device_connection.activate_status_request_handler(self.output) - - async def async_will_remove_from_hass(self) -> None: - """Run when entity will be removed from hass.""" - await super().async_will_remove_from_hass() - if not self.device_connection.is_group: - await self.device_connection.cancel_status_request_handler(self.output) - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" states = [pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8 @@ -156,6 +140,10 @@ async def async_turn_off(self, **kwargs: Any) -> None: self._attr_is_on = False self.async_write_ha_state() + async def async_update(self) -> None: + """Update the state of the entity.""" + await self.device_connection.request_status_relays(SCAN_INTERVAL.seconds) + def input_received(self, input_obj: InputType) -> None: """Set switch state when LCN input object (command) is received.""" if not isinstance(input_obj, pypck.inputs.ModStatusRelays): @@ -179,22 +167,6 @@ def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: ] self.reg_id = pypck.lcn_defs.Var.to_set_point_id(self.setpoint_variable) - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - await super().async_added_to_hass() - if not self.device_connection.is_group: - await self.device_connection.activate_status_request_handler( - self.setpoint_variable - ) - - async def async_will_remove_from_hass(self) -> None: - """Run when entity will be removed from hass.""" - await super().async_will_remove_from_hass() - if not self.device_connection.is_group: - await self.device_connection.cancel_status_request_handler( - self.setpoint_variable - ) - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" if not await self.device_connection.lock_regulator(self.reg_id, True): @@ -209,6 +181,12 @@ async def async_turn_off(self, **kwargs: Any) -> None: self._attr_is_on = False self.async_write_ha_state() + async def async_update(self) -> None: + """Update the state of the entity.""" + await self.device_connection.request_status_variable( + self.setpoint_variable, SCAN_INTERVAL.seconds + ) + def input_received(self, input_obj: InputType) -> None: """Set switch state when LCN input object (command) is received.""" if ( @@ -234,18 +212,6 @@ def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: self.table_id = ord(self.key.name[0]) - 65 self.key_id = int(self.key.name[1]) - 1 - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - await super().async_added_to_hass() - if not self.device_connection.is_group: - await self.device_connection.activate_status_request_handler(self.key) - - async def async_will_remove_from_hass(self) -> None: - """Run when entity will be removed from hass.""" - await super().async_will_remove_from_hass() - if not self.device_connection.is_group: - await self.device_connection.cancel_status_request_handler(self.key) - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" states = [pypck.lcn_defs.KeyLockStateModifier.NOCHANGE] * 8 @@ -268,6 +234,10 @@ async def async_turn_off(self, **kwargs: Any) -> None: self._attr_is_on = False self.async_write_ha_state() + async def async_update(self) -> None: + """Update the state of the entity.""" + await self.device_connection.request_status_locked_keys(SCAN_INTERVAL.seconds) + def input_received(self, input_obj: InputType) -> None: """Set switch state when LCN input object (command) is received.""" if ( diff --git a/homeassistant/components/satel_integra/quality_scale.yaml b/homeassistant/components/satel_integra/quality_scale.yaml index dc1c269dea26fc..8c62a72017bc46 100644 --- a/homeassistant/components/satel_integra/quality_scale.yaml +++ b/homeassistant/components/satel_integra/quality_scale.yaml @@ -7,23 +7,23 @@ rules: status: exempt comment: This integration does not poll. brands: done - common-modules: todo - config-flow-test-coverage: todo - config-flow: todo + common-modules: done + config-flow-test-coverage: done + config-flow: done dependency-transparency: todo docs-actions: status: exempt comment: This integration does not provide any service actions. - docs-high-level-description: todo - docs-installation-instructions: todo - docs-removal-instructions: todo - entity-event-setup: todo - entity-unique-id: todo - has-entity-name: todo + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done runtime-data: done test-before-configure: done test-before-setup: done - unique-config-entry: todo + unique-config-entry: done # Silver action-exceptions: todo diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 3bdcc68523c1f2..29e457eb490978 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -32,12 +32,82 @@ from .models import ( DPCodeBooleanWrapper, DPCodeEnumWrapper, + DPCodeIntegerWrapper, IntegerTypeData, find_dpcode, ) from .util import get_dpcode, get_dptype, remap_value +class _BrightnessWrapper(DPCodeIntegerWrapper): + """Wrapper for brightness DP code. + + Handles brightness value conversion between device scale and Home Assistant's + 0-255 scale. Supports optional dynamic brightness_min and brightness_max + wrappers that allow the device to specify runtime brightness range limits. + """ + + brightness_min: DPCodeIntegerWrapper | None = None + brightness_max: DPCodeIntegerWrapper | None = None + + def read_device_status(self, device: CustomerDevice) -> Any | None: + """Return the brightness of this light between 0..255.""" + if (brightness := self._read_device_status_raw(device)) is None: + return None + + # Remap value to our scale + brightness = self.type_information.remap_value_to(brightness) + + # If there is a min/max value, the brightness is actually limited. + # Meaning it is actually not on a 0-255 scale. + if ( + self.brightness_max is not None + and self.brightness_min is not None + and (brightness_max := device.status.get(self.brightness_max.dpcode)) + is not None + and (brightness_min := device.status.get(self.brightness_min.dpcode)) + is not None + ): + # Remap values onto our scale + brightness_max = self.brightness_max.type_information.remap_value_to( + brightness_max + ) + brightness_min = self.brightness_min.type_information.remap_value_to( + brightness_min + ) + + # Remap the brightness value from their min-max to our 0-255 scale + brightness = remap_value( + brightness, from_min=brightness_min, from_max=brightness_max + ) + + return round(brightness) + + def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any: + """Convert a Home Assistant value (0..255) back to a raw device value.""" + # If there is a min/max value, the brightness is actually limited. + # Meaning it is actually not on a 0-255 scale. + if ( + self.brightness_max is not None + and self.brightness_min is not None + and (brightness_max := device.status.get(self.brightness_max.dpcode)) + is not None + and (brightness_min := device.status.get(self.brightness_min.dpcode)) + is not None + ): + # Remap values onto our scale + brightness_max = self.brightness_max.type_information.remap_value_to( + brightness_max + ) + brightness_min = self.brightness_min.type_information.remap_value_to( + brightness_min + ) + + # Remap the brightness value from our 0-255 scale to their min-max + value = remap_value(value, to_min=brightness_min, to_max=brightness_max) + return round(self.type_information.remap_value_from(value)) + + @dataclass class ColorTypeData: """Color Type Data.""" @@ -417,6 +487,24 @@ def brightness(self) -> int: return round(self.type_data.v_type.remap_value_to(self.v_value, 0, 255)) +def _get_brightness_wrapper( + device: CustomerDevice, description: TuyaLightEntityDescription +) -> _BrightnessWrapper | None: + if ( + brightness_wrapper := _BrightnessWrapper.find_dpcode( + device, description.brightness, prefer_function=True + ) + ) is None: + return None + brightness_wrapper.brightness_max = DPCodeIntegerWrapper.find_dpcode( + device, description.brightness_max, prefer_function=True + ) + brightness_wrapper.brightness_min = DPCodeIntegerWrapper.find_dpcode( + device, description.brightness_min, prefer_function=True + ) + return brightness_wrapper + + async def async_setup_entry( hass: HomeAssistant, entry: TuyaConfigEntry, @@ -437,6 +525,7 @@ def async_discover_device(device_ids: list[str]): device, manager, description, + brightness_wrapper=_get_brightness_wrapper(device, description), color_mode_wrapper=DPCodeEnumWrapper.find_dpcode( device, description.color_mode, prefer_function=True ), @@ -464,9 +553,6 @@ class TuyaLightEntity(TuyaEntity, LightEntity): entity_description: TuyaLightEntityDescription - _brightness_max: IntegerTypeData | None = None - _brightness_min: IntegerTypeData | None = None - _brightness: IntegerTypeData | None = None _color_data_dpcode: DPCode | None = None _color_data_type: ColorTypeData | None = None _color_temp: IntegerTypeData | None = None @@ -481,6 +567,7 @@ def __init__( device_manager: Manager, description: TuyaLightEntityDescription, *, + brightness_wrapper: DPCodeIntegerWrapper | None, color_mode_wrapper: DPCodeEnumWrapper | None, switch_wrapper: DPCodeBooleanWrapper, ) -> None: @@ -488,25 +575,14 @@ def __init__( super().__init__(device, device_manager) self.entity_description = description self._attr_unique_id = f"{super().unique_id}{description.key}" + self._brightness_wrapper = brightness_wrapper self._color_mode_wrapper = color_mode_wrapper self._switch_wrapper = switch_wrapper color_modes: set[ColorMode] = {ColorMode.ONOFF} - if int_type := find_dpcode( - self.device, - description.brightness, - dptype=DPType.INTEGER, - prefer_function=True, - ): - self._brightness = int_type + if brightness_wrapper: color_modes.add(ColorMode.BRIGHTNESS) - self._brightness_max = find_dpcode( - self.device, description.brightness_max, dptype=DPType.INTEGER - ) - self._brightness_min = find_dpcode( - self.device, description.brightness_min, dptype=DPType.INTEGER - ) if (dpcode := get_dpcode(self.device, description.color_data)) and ( get_dptype(self.device, dpcode, prefer_function=True) == DPType.JSON @@ -529,7 +605,8 @@ def __init__( # If no type is found, use a default one self._color_data_type = self.entity_description.default_color_type if self._color_data_dpcode == DPCode.COLOUR_DATA_V2 or ( - self._brightness and self._brightness.max > 255 + self._brightness_wrapper + and self._brightness_wrapper.type_information.max > 255 ): self._color_data_type = DEFAULT_COLOR_TYPE_DATA_V2 @@ -641,46 +718,16 @@ def turn_on(self, **kwargs: Any) -> None: }, ] - elif self._brightness and (ATTR_BRIGHTNESS in kwargs or ATTR_WHITE in kwargs): + elif self._brightness_wrapper and ( + ATTR_BRIGHTNESS in kwargs or ATTR_WHITE in kwargs + ): if ATTR_BRIGHTNESS in kwargs: brightness = kwargs[ATTR_BRIGHTNESS] else: brightness = kwargs[ATTR_WHITE] - # If there is a min/max value, the brightness is actually limited. - # Meaning it is actually not on a 0-255 scale. - if ( - self._brightness_max is not None - and self._brightness_min is not None - and ( - brightness_max := self.device.status.get( - self._brightness_max.dpcode - ) - ) - is not None - and ( - brightness_min := self.device.status.get( - self._brightness_min.dpcode - ) - ) - is not None - ): - # Remap values onto our scale - brightness_max = self._brightness_max.remap_value_to(brightness_max) - brightness_min = self._brightness_min.remap_value_to(brightness_min) - - # Remap the brightness value from their min-max to our 0-255 scale - brightness = remap_value( - brightness, - to_min=brightness_min, - to_max=brightness_max, - ) - commands += [ - { - "code": self._brightness.dpcode, - "value": round(self._brightness.remap_value_from(brightness)), - }, + self._brightness_wrapper.get_update_command(self.device, brightness), ] self._send_command(commands) @@ -691,43 +738,12 @@ async def async_turn_off(self, **kwargs: Any) -> None: @property def brightness(self) -> int | None: - """Return the brightness of the light.""" + """Return the brightness of this light between 0..255.""" # If the light is currently in color mode, extract the brightness from the color data if self.color_mode == ColorMode.HS and (color_data := self._get_color_data()): return color_data.brightness - if not self._brightness: - return None - - brightness = self.device.status.get(self._brightness.dpcode) - if brightness is None: - return None - - # Remap value to our scale - brightness = self._brightness.remap_value_to(brightness) - - # If there is a min/max value, the brightness is actually limited. - # Meaning it is actually not on a 0-255 scale. - if ( - self._brightness_max is not None - and self._brightness_min is not None - and (brightness_max := self.device.status.get(self._brightness_max.dpcode)) - is not None - and (brightness_min := self.device.status.get(self._brightness_min.dpcode)) - is not None - ): - # Remap values onto our scale - brightness_max = self._brightness_max.remap_value_to(brightness_max) - brightness_min = self._brightness_min.remap_value_to(brightness_min) - - # Remap the brightness value from their min-max to our 0-255 scale - brightness = remap_value( - brightness, - from_min=brightness_min, - from_max=brightness_max, - ) - - return round(brightness) + return self._read_wrapper(self._brightness_wrapper) @property def color_temp_kelvin(self) -> int | None: diff --git a/homeassistant/components/xbox/browse_media.py b/homeassistant/components/xbox/browse_media.py index ab311e244b5ab1..c5f1a5caf89017 100644 --- a/homeassistant/components/xbox/browse_media.py +++ b/homeassistant/components/xbox/browse_media.py @@ -130,7 +130,7 @@ async def build_item_response( ) -def item_payload(item: InstalledPackage, images: dict[str, list[Image]]): +def item_payload(item: InstalledPackage, images: dict[str, list[Image]]) -> BrowseMedia: """Create response payload for a single media item.""" thumbnail = None image = _find_media_image(images.get(item.one_store_product_id, [])) # type: ignore[arg-type] diff --git a/homeassistant/components/xbox/sensor.py b/homeassistant/components/xbox/sensor.py index 45d5c7a6e47f7f..91ddb2f3fe3370 100644 --- a/homeassistant/components/xbox/sensor.py +++ b/homeassistant/components/xbox/sensor.py @@ -335,7 +335,7 @@ def __init__( ) @property - def data(self): + def data(self) -> StorageDevice | None: """Storage device data.""" consoles = self.coordinator.data.result console = next((c for c in consoles if c.id == self._console.id), None) diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ddff8ad4232bef..e22efdde2ad9e6 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3418,7 +3418,7 @@ "name": "LCN", "integration_type": "hub", "config_flow": true, - "iot_class": "local_push" + "iot_class": "local_polling" }, "ld2410_ble": { "name": "LD2410 BLE", diff --git a/mypy.ini b/mypy.ini index fe18814bde0154..2fdd78cffa58e6 100644 --- a/mypy.ini +++ b/mypy.ini @@ -5549,6 +5549,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.xbox.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.xiaomi_ble.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 107cdbac3f3529..abd74e3c7fe01d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2063,7 +2063,7 @@ pyhomeworks==1.1.2 pyialarm==2.2.0 # homeassistant.components.icloud -pyicloud==2.1.0 +pyicloud==2.2.0 # homeassistant.components.insteon pyinsteon==1.6.3 @@ -2269,7 +2269,7 @@ pypaperless==4.1.1 pypca==0.0.7 # homeassistant.components.lcn -pypck==0.8.12 +pypck==0.9.2 # homeassistant.components.pglab pypglab==0.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4f64c9f8fb1776..0dde3eb6b7ddfb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1722,7 +1722,7 @@ pyhomeworks==1.1.2 pyialarm==2.2.0 # homeassistant.components.icloud -pyicloud==2.1.0 +pyicloud==2.2.0 # homeassistant.components.insteon pyinsteon==1.6.3 @@ -1892,7 +1892,7 @@ pypalazzetti==0.1.20 pypaperless==4.1.1 # homeassistant.components.lcn -pypck==0.8.12 +pypck==0.9.2 # homeassistant.components.pglab pypglab==0.0.5 diff --git a/tests/components/icloud/const.py b/tests/components/icloud/const.py index 463ae6a7da23c5..d2bbfeb717c38f 100644 --- a/tests/components/icloud/const.py +++ b/tests/components/icloud/const.py @@ -10,6 +10,8 @@ ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +FIRST_NAME = "user" +LAST_NAME = "name" USERNAME = "username@me.com" USERNAME_2 = "second_username@icloud.com" PASSWORD = "password" @@ -18,6 +20,30 @@ MAX_INTERVAL = 15 GPS_ACCURACY_THRESHOLD = 250 +MEMBER_1_FIRST_NAME = "John" +MEMBER_1_LAST_NAME = "TRAVOLTA" +MEMBER_1_FULL_NAME = MEMBER_1_FIRST_NAME + " " + MEMBER_1_LAST_NAME +MEMBER_1_PERSON_ID = (MEMBER_1_FIRST_NAME + MEMBER_1_LAST_NAME).lower() +MEMBER_1_APPLE_ID = MEMBER_1_PERSON_ID + "@icloud.com" + +USER_INFO = { + "accountFormatter": 0, + "firstName": FIRST_NAME, + "lastName": LAST_NAME, + "membersInfo": { + MEMBER_1_PERSON_ID: { + "accountFormatter": 0, + "firstName": MEMBER_1_FIRST_NAME, + "lastName": MEMBER_1_LAST_NAME, + "deviceFetchStatus": "DONE", + "useAuthWidget": True, + "isHSA": True, + "appleId": MEMBER_1_APPLE_ID, + } + }, + "hasMembers": True, +} + MOCK_CONFIG = { CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, @@ -29,3 +55,17 @@ TRUSTED_DEVICES = [ {"deviceType": "SMS", "areaCode": "", "phoneNumber": "*******58", "deviceId": "1"} ] + +DEVICE = { + "id": "device1", + "name": "iPhone", + "deviceStatus": "200", + "batteryStatus": "NotCharging", + "batteryLevel": 0.8, + "rawDeviceModel": "iPhone14,2", + "deviceClass": "iPhone", + "deviceDisplayName": "iPhone", + "prsId": None, + "lowPowerMode": False, + "location": None, +} diff --git a/tests/components/icloud/test_account.py b/tests/components/icloud/test_account.py new file mode 100644 index 00000000000000..52e6799f417497 --- /dev/null +++ b/tests/components/icloud/test_account.py @@ -0,0 +1,167 @@ +"""Tests for the iCloud account.""" + +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from homeassistant.components.icloud.account import IcloudAccount +from homeassistant.components.icloud.const import ( + CONF_GPS_ACCURACY_THRESHOLD, + CONF_MAX_INTERVAL, + CONF_WITH_FAMILY, + DOMAIN, +) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.storage import Store + +from .const import DEVICE, MOCK_CONFIG, USER_INFO, USERNAME + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="mock_store") +def mock_store_fixture(): + """Mock the storage.""" + with patch("homeassistant.components.icloud.account.Store") as store_mock: + store_instance = Mock(spec=Store) + store_instance.path = "/mock/path" + store_mock.return_value = store_instance + yield store_instance + + +@pytest.fixture(name="mock_icloud_service_no_userinfo") +def mock_icloud_service_no_userinfo_fixture(): + """Mock PyiCloudService with devices as dict but no userInfo.""" + with patch( + "homeassistant.components.icloud.account.PyiCloudService" + ) as service_mock: + service_instance = MagicMock() + service_instance.requires_2fa = False + mock_device = MagicMock() + mock_device.status = iter(DEVICE) + mock_device.user_info = None + service_instance.devices = mock_device + service_mock.return_value = service_instance + yield service_instance + + +async def test_setup_fails_when_userinfo_missing( + hass: HomeAssistant, + mock_store: Mock, + mock_icloud_service_no_userinfo: MagicMock, +) -> None: + """Test setup fails when userInfo is missing from devices dict.""" + + assert mock_icloud_service_no_userinfo is not None + + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, entry_id="test", unique_id=USERNAME + ) + config_entry.add_to_hass(hass) + + account = IcloudAccount( + hass, + MOCK_CONFIG[CONF_USERNAME], + MOCK_CONFIG[CONF_PASSWORD], + mock_store, + MOCK_CONFIG[CONF_WITH_FAMILY], + MOCK_CONFIG[CONF_MAX_INTERVAL], + MOCK_CONFIG[CONF_GPS_ACCURACY_THRESHOLD], + config_entry, + ) + + with pytest.raises(ConfigEntryNotReady, match="No user info found"): + account.setup() + + +class MockAppleDevice: + """Mock "Apple device" which implements the .status(...) method used by the account.""" + + def __init__(self, status_dict) -> None: + """Set status.""" + self._status = status_dict + + def status(self, key): + """Return current status.""" + return self._status + + def __getitem__(self, key): + """Allow indexing the device itself (device[KEY]) to proxy into the raw status dict.""" + return self._status.get(key) + + +class MockDevicesContainer: + """Mock devices container which is iterable and indexable returning device status dicts.""" + + def __init__(self, userinfo, devices) -> None: + """Initialize with userinfo and list of device objects.""" + self.user_info = userinfo + self._devices = devices + + def __iter__(self): + """Iterate returns device objects (each must have .status(...)).""" + return iter(self._devices) + + def __len__(self): + """Return number of devices.""" + return len(self._devices) + + def __getitem__(self, idx): + """Indexing returns device object (which must have .status(...)).""" + dev = self._devices[idx] + if hasattr(dev, "status"): + return dev.status(None) + return dev + + +@pytest.fixture(name="mock_icloud_service") +def mock_icloud_service_fixture(): + """Mock PyiCloudService with devices container that is iterable and indexable returning status dict.""" + with patch( + "homeassistant.components.icloud.account.PyiCloudService", + ) as service_mock: + service_instance = MagicMock() + device_obj = MockAppleDevice(DEVICE) + devices_container = MockDevicesContainer(USER_INFO, [device_obj]) + + service_instance.devices = devices_container + service_instance.requires_2fa = False + + service_mock.return_value = service_instance + yield service_instance + + +async def test_setup_success_with_devices( + hass: HomeAssistant, + mock_store: Mock, + mock_icloud_service: MagicMock, +) -> None: + """Test successful setup with devices.""" + + assert mock_icloud_service is not None + + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, entry_id="test", unique_id=USERNAME + ) + config_entry.add_to_hass(hass) + + account = IcloudAccount( + hass, + MOCK_CONFIG[CONF_USERNAME], + MOCK_CONFIG[CONF_PASSWORD], + mock_store, + MOCK_CONFIG[CONF_WITH_FAMILY], + MOCK_CONFIG[CONF_MAX_INTERVAL], + MOCK_CONFIG[CONF_GPS_ACCURACY_THRESHOLD], + config_entry, + ) + + with patch.object(account, "_schedule_next_fetch"): + account.setup() + + assert account.api is not None + assert account.owner_fullname == "user name" + assert "johntravolta" in account.family_members_fullname + assert account.family_members_fullname["johntravolta"] == "John TRAVOLTA" diff --git a/tests/components/kostal_plenticore/conftest.py b/tests/components/kostal_plenticore/conftest.py index bedcea4ddc2f00..22343e590676e0 100644 --- a/tests/components/kostal_plenticore/conftest.py +++ b/tests/components/kostal_plenticore/conftest.py @@ -2,18 +2,67 @@ from __future__ import annotations -from collections.abc import Generator -from unittest.mock import AsyncMock, MagicMock, patch +from collections.abc import Generator, Iterable +import copy +from unittest.mock import patch -from pykoplenti import MeData, VersionData +from pykoplenti import ExtendedApiClient, MeData, SettingsData, VersionData import pytest -from homeassistant.components.kostal_plenticore.coordinator import Plenticore from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from tests.common import MockConfigEntry +DEFAULT_SETTING_VALUES = { + "devices:local": { + "Properties:StringCnt": "2", + "Properties:String0Features": "1", + "Properties:String1Features": "1", + "Properties:SerialNo": "42", + "Branding:ProductName1": "PLENTICORE", + "Branding:ProductName2": "plus 10", + "Properties:VersionIOC": "01.45", + "Properties:VersionMC": "01.46", + "Battery:MinSoc": "5", + "Battery:MinHomeComsumption": "50", + }, + "scb:network": {"Hostname": "scb"}, +} + +DEFAULT_SETTINGS = { + "devices:local": [ + SettingsData( + min="5", + max="100", + default=None, + access="readwrite", + unit="%", + id="Battery:MinSoc", + type="byte", + ), + SettingsData( + min="50", + max="38000", + default=None, + access="readwrite", + unit="W", + id="Battery:MinHomeComsumption", + type="byte", + ), + ], + "scb:network": [ + SettingsData( + min="1", + max="63", + default=None, + access="readwrite", + unit=None, + id="Hostname", + type="string", + ) + ], +} + @pytest.fixture def mock_config_entry() -> MockConfigEntry: @@ -42,37 +91,67 @@ def mock_installer_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_plenticore() -> Generator[Plenticore]: - """Set up a Plenticore mock with some default values.""" - with patch( - "homeassistant.components.kostal_plenticore.Plenticore", autospec=True - ) as mock_api_class: - # setup - plenticore = mock_api_class.return_value - plenticore.async_setup = AsyncMock() - plenticore.async_setup.return_value = True - - plenticore.device_info = DeviceInfo( - configuration_url="http://192.168.1.2", - identifiers={("kostal_plenticore", "12345")}, - manufacturer="Kostal", - model="PLENTICORE plus 10", - name="scb", - sw_version="IOC: 01.45 MC: 01.46", - ) +def mock_get_settings() -> dict[str, list[SettingsData]]: + """Add setting data to mock_plenticore_client. - plenticore.client = MagicMock() + Returns a dictionary with setting data which can be mutated by test cases. + """ + return copy.deepcopy(DEFAULT_SETTINGS) - plenticore.client.get_version = AsyncMock() - plenticore.client.get_version.return_value = VersionData( - api_version="0.2.0", - hostname="scb", - name="PUCK RESTful API", - sw_version="01.16.05025", - ) - plenticore.client.get_me = AsyncMock() - plenticore.client.get_me.return_value = MeData( +@pytest.fixture +def mock_get_setting_values() -> dict[str, dict[str, str]]: + """Add setting values to mock_plenticore_client. + + Returns a dictionary with setting values which can be mutated by test cases. + """ + # Add default settings values - this values are always retrieved by the integration on startup + return copy.deepcopy(DEFAULT_SETTING_VALUES) + + +@pytest.fixture +def mock_plenticore_client( + mock_get_settings: dict[str, list[SettingsData]], + mock_get_setting_values: dict[str, dict[str, str]], +) -> Generator[ExtendedApiClient]: + """Return a patched ExtendedApiClient.""" + with patch( + "homeassistant.components.kostal_plenticore.coordinator.ExtendedApiClient", + autospec=True, + ) as plenticore_client_class: + + def default_settings_data(*args): + # the get_setting_values method can be called with different argument types and numbers + match args: + case (str() as module_id, str() as data_id): + request = {module_id: [data_id]} + case (str() as module_id, Iterable() as data_ids): + request = {module_id: data_ids} + case ({},): + request = args[0] + case _: + raise NotImplementedError + + result = {} + for module_id, data_ids in request.items(): + if (values := mock_get_setting_values.get(module_id)) is not None: + result[module_id] = {} + for data_id in data_ids: + if data_id in values: + result[module_id][data_id] = values[data_id] + else: + raise ValueError( + f"Missing data_id {data_id} in module {module_id}" + ) + else: + raise ValueError(f"Missing module_id {module_id}") + + return result + + client = plenticore_client_class.return_value + client.get_setting_values.side_effect = default_settings_data + client.get_settings.return_value = mock_get_settings + client.get_me.return_value = MeData( locked=False, active=True, authenticated=True, @@ -80,11 +159,14 @@ def mock_plenticore() -> Generator[Plenticore]: anonymous=False, role="USER", ) + client.get_version.return_value = VersionData( + api_version="0.2.0", + hostname="scb", + name="PUCK RESTful API", + sw_version="01.16.05025", + ) - plenticore.client.get_process_data = AsyncMock() - plenticore.client.get_settings = AsyncMock() - - yield plenticore + yield client @pytest.fixture diff --git a/tests/components/kostal_plenticore/test_diagnostics.py b/tests/components/kostal_plenticore/test_diagnostics.py index 3a99a7f681d9c9..626a9aa93aadfa 100644 --- a/tests/components/kostal_plenticore/test_diagnostics.py +++ b/tests/components/kostal_plenticore/test_diagnostics.py @@ -1,9 +1,8 @@ """Test Kostal Plenticore diagnostics.""" -from pykoplenti import SettingsData +from unittest.mock import Mock from homeassistant.components.diagnostics import REDACTED -from homeassistant.components.kostal_plenticore.coordinator import Plenticore from homeassistant.core import HomeAssistant from tests.common import ANY, MockConfigEntry @@ -14,30 +13,16 @@ async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, - mock_plenticore: Plenticore, + mock_plenticore_client: Mock, init_integration: MockConfigEntry, ) -> None: """Test config entry diagnostics.""" - # set some test process and settings data for the diagnostics output - mock_plenticore.client.get_process_data.return_value = { + # set some test process data for the diagnostics output + mock_plenticore_client.get_process_data.return_value = { "devices:local": ["HomeGrid_P", "HomePv_P"] } - mock_plenticore.client.get_settings.return_value = { - "devices:local": [ - SettingsData( - min="5", - max="100", - default=None, - access="readwrite", - unit="%", - id="Battery:MinSoc", - type="byte", - ) - ] - } - assert await get_diagnostics_for_config_entry( hass, hass_client, init_integration ) == { @@ -65,8 +50,19 @@ async def test_entry_diagnostics( "available_process_data": {"devices:local": ["HomeGrid_P", "HomePv_P"]}, "available_settings_data": { "devices:local": [ - "min='5' max='100' default=None access='readwrite' unit='%' id='Battery:MinSoc' type='byte'" - ] + "min='5' max='100' default=None access='readwrite' unit='%' id='Battery:MinSoc' type='byte'", + "min='50' max='38000' default=None access='readwrite' unit='W' id='Battery:MinHomeComsumption' type='byte'", + ], + "scb:network": [ + "min='1' max='63' default=None access='readwrite' unit=None id='Hostname' type='string'" + ], + }, + }, + "configuration": { + "devices:local": { + "Properties:StringCnt": "2", + "Properties:String0Features": "1", + "Properties:String1Features": "1", }, }, "device": { @@ -78,3 +74,28 @@ async def test_entry_diagnostics( "sw_version": "IOC: 01.45 MC: 01.46", }, } + + +async def test_entry_diagnostics_invalid_string_count( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_plenticore_client: Mock, + mock_get_setting_values: Mock, + init_integration: MockConfigEntry, +) -> None: + """Test config entry diagnostics if string count is invalid.""" + + # set some test process data for the diagnostics output + mock_plenticore_client.get_process_data.return_value = { + "devices:local": ["HomeGrid_P", "HomePv_P"] + } + + mock_get_setting_values["devices:local"]["Properties:StringCnt"] = "invalid" + + diagnostic_data = await get_diagnostics_for_config_entry( + hass, hass_client, init_integration + ) + + assert diagnostic_data["configuration"] == { + "devices:local": {"Properties:StringCnt": "invalid"} + } diff --git a/tests/components/kostal_plenticore/test_number.py b/tests/components/kostal_plenticore/test_number.py index 586129c486d03b..64dc7d3c80c112 100644 --- a/tests/components/kostal_plenticore/test_number.py +++ b/tests/components/kostal_plenticore/test_number.py @@ -1,8 +1,6 @@ """Test Kostal Plenticore number.""" -from collections.abc import Generator from datetime import timedelta -from unittest.mock import patch from pykoplenti import ApiClient, SettingsData import pytest @@ -21,75 +19,9 @@ from tests.common import MockConfigEntry, async_fire_time_changed - -@pytest.fixture -def mock_plenticore_client() -> Generator[ApiClient]: - """Return a patched ExtendedApiClient.""" - with patch( - "homeassistant.components.kostal_plenticore.coordinator.ExtendedApiClient", - autospec=True, - ) as plenticore_client_class: - yield plenticore_client_class.return_value - - -@pytest.fixture -def mock_get_setting_values(mock_plenticore_client: ApiClient) -> list: - """Add a setting value to the given Plenticore client. - - Returns a list with setting values which can be extended by test cases. - """ - - mock_plenticore_client.get_settings.return_value = { - "devices:local": [ - SettingsData( - min="5", - max="100", - default=None, - access="readwrite", - unit="%", - id="Battery:MinSoc", - type="byte", - ), - SettingsData( - min="50", - max="38000", - default=None, - access="readwrite", - unit="W", - id="Battery:MinHomeComsumption", - type="byte", - ), - ], - "scb:network": [ - SettingsData( - min="1", - max="63", - default=None, - access="readwrite", - unit=None, - id="Hostname", - type="string", - ) - ], - } - - # this values are always retrieved by the integration on startup - setting_values = [ - { - "devices:local": { - "Properties:SerialNo": "42", - "Branding:ProductName1": "PLENTICORE", - "Branding:ProductName2": "plus 10", - "Properties:VersionIOC": "01.45", - "Properties:VersionMC": " 01.46", - }, - "scb:network": {"Hostname": "scb"}, - } - ] - - mock_plenticore_client.get_setting_values.side_effect = setting_values - - return setting_values +pytestmark = [ + pytest.mark.usefixtures("mock_plenticore_client"), +] @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -97,8 +29,6 @@ async def test_setup_all_entries( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, - mock_plenticore_client: ApiClient, - mock_get_setting_values: list, ) -> None: """Test if all available entries are setup.""" @@ -118,25 +48,27 @@ async def test_setup_no_entries( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, - mock_plenticore_client: ApiClient, - mock_get_setting_values: list, + mock_get_settings: dict[str, list[SettingsData]], ) -> None: """Test that no entries are setup if Plenticore does not provide data.""" # remove all settings except hostname which is used during setup - mock_plenticore_client.get_settings.return_value = { - "scb:network": [ - SettingsData( - min="1", - max="63", - default=None, - access="readwrite", - unit=None, - id="Hostname", - type="string", - ) - ], - } + mock_get_settings.clear() + mock_get_settings.update( + { + "scb:network": [ + SettingsData( + min="1", + max="63", + default=None, + access="readwrite", + unit=None, + id="Hostname", + type="string", + ) + ] + } + ) mock_config_entry.add_to_hass(hass) @@ -151,12 +83,11 @@ async def test_setup_no_entries( async def test_number_has_value( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_plenticore_client: ApiClient, - mock_get_setting_values: list, + mock_get_setting_values: dict[str, dict[str, str]], ) -> None: """Test if number has a value if data is provided on update.""" - mock_get_setting_values.append({"devices:local": {"Battery:MinSoc": "42"}}) + mock_get_setting_values["devices:local"]["Battery:MinSoc"] = "42" mock_config_entry.add_to_hass(hass) @@ -176,11 +107,12 @@ async def test_number_has_value( async def test_number_is_unavailable( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_plenticore_client: ApiClient, - mock_get_setting_values: list, + mock_get_setting_values: dict[str, dict[str, str]], ) -> None: """Test if number is unavailable if no data is provided on update.""" + del mock_get_setting_values["devices:local"]["Battery:MinSoc"] + mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) @@ -198,11 +130,11 @@ async def test_set_value( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_plenticore_client: ApiClient, - mock_get_setting_values: list, + mock_get_setting_values: dict[str, dict[str, str]], ) -> None: """Test if a new value could be set.""" - mock_get_setting_values.append({"devices:local": {"Battery:MinSoc": "42"}}) + mock_get_setting_values["devices:local"]["Battery:MinSoc"] = "42" mock_config_entry.add_to_hass(hass) diff --git a/tests/components/kostal_plenticore/test_select.py b/tests/components/kostal_plenticore/test_select.py index e3fc136a3fbdec..af6c12d596fbd2 100644 --- a/tests/components/kostal_plenticore/test_select.py +++ b/tests/components/kostal_plenticore/test_select.py @@ -1,24 +1,75 @@ """Test the Kostal Plenticore Solar Inverter select platform.""" from pykoplenti import SettingsData +import pytest -from homeassistant.components.kostal_plenticore.coordinator import Plenticore from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry +pytestmark = [ + pytest.mark.usefixtures("mock_plenticore_client"), +] + async def test_select_battery_charging_usage_available( hass: HomeAssistant, - mock_plenticore: Plenticore, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, + mock_get_settings: dict[str, list[SettingsData]], ) -> None: """Test that the battery charging usage select entity is added if the settings are available.""" - mock_plenticore.client.get_settings.return_value = { - "devices:local": [ + mock_get_settings["devices:local"].extend( + [ + SettingsData( + min=None, + max=None, + default=None, + access="readwrite", + unit=None, + id="Battery:SmartBatteryControl:Enable", + type="string", + ), + SettingsData( + min=None, + max=None, + default=None, + access="readwrite", + unit=None, + id="Battery:TimeControl:Enable", + type="string", + ), + ] + ) + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert entity_registry.async_is_registered("select.battery_charging_usage_mode") + + entity = entity_registry.async_get("select.battery_charging_usage_mode") + assert entity.capabilities.get("options") == [ + "None", + "Battery:SmartBatteryControl:Enable", + "Battery:TimeControl:Enable", + ] + + +async def test_select_battery_charging_usage_excess_energy_available( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + mock_get_settings: dict[str, list[SettingsData]], + mock_get_setting_values: dict[str, dict[str, str]], +) -> None: + """Test that the battery charging usage select entity contains the option for excess AC energy.""" + + mock_get_settings["devices:local"].extend( + [ SettingsData( min=None, max=None, @@ -38,7 +89,7 @@ async def test_select_battery_charging_usage_available( type="string", ), ] - } + ) mock_config_entry.add_to_hass(hass) @@ -47,10 +98,16 @@ async def test_select_battery_charging_usage_available( assert entity_registry.async_is_registered("select.battery_charging_usage_mode") + entity = entity_registry.async_get("select.battery_charging_usage_mode") + assert entity.capabilities.get("options") == [ + "None", + "Battery:SmartBatteryControl:Enable", + "Battery:TimeControl:Enable", + ] + async def test_select_battery_charging_usage_not_available( hass: HomeAssistant, - mock_plenticore: Plenticore, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, ) -> None: diff --git a/tests/components/kostal_plenticore/test_switch.py b/tests/components/kostal_plenticore/test_switch.py index 0dd4c958fd5ee1..d1b25442be6e8a 100644 --- a/tests/components/kostal_plenticore/test_switch.py +++ b/tests/components/kostal_plenticore/test_switch.py @@ -1,35 +1,52 @@ """Test the Kostal Plenticore Solar Inverter switch platform.""" +from datetime import timedelta +from unittest.mock import Mock + from pykoplenti import SettingsData +import pytest -from homeassistant.components.kostal_plenticore.coordinator import Plenticore +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed -from tests.common import MockConfigEntry +pytestmark = [ + pytest.mark.usefixtures("mock_plenticore_client"), +] async def test_installer_setting_not_available( hass: HomeAssistant, - mock_plenticore: Plenticore, + mock_get_settings: dict[str, list[SettingsData]], mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, ) -> None: """Test that the manual charge setting is not available when not using the installer login.""" - - mock_plenticore.client.get_settings.return_value = { - "devices:local": [ - SettingsData( - min=None, - max=None, - default=None, - access="readwrite", - unit=None, - id="Battery:ManualCharge", - type="bool", - ) - ] - } + mock_get_settings.update( + { + "devices:local": [ + SettingsData( + min=None, + max=None, + default=None, + access="readwrite", + unit=None, + id="Battery:ManualCharge", + type="bool", + ) + ] + } + ) mock_config_entry.add_to_hass(hass) @@ -41,25 +58,26 @@ async def test_installer_setting_not_available( async def test_installer_setting_available( hass: HomeAssistant, - mock_plenticore: Plenticore, + mock_get_settings: dict[str, list[SettingsData]], mock_installer_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, ) -> None: """Test that the manual charge setting is available when using the installer login.""" - - mock_plenticore.client.get_settings.return_value = { - "devices:local": [ - SettingsData( - min=None, - max=None, - default=None, - access="readwrite", - unit=None, - id="Battery:ManualCharge", - type="bool", - ) - ] - } + mock_get_settings.update( + { + "devices:local": [ + SettingsData( + min=None, + max=None, + default=None, + access="readwrite", + unit=None, + id="Battery:ManualCharge", + type="bool", + ) + ] + } + ) mock_installer_config_entry.add_to_hass(hass) @@ -67,3 +85,112 @@ async def test_installer_setting_available( await hass.async_block_till_done() assert entity_registry.async_is_registered("switch.scb_battery_manual_charge") + + +async def test_invalid_string_count_value( + hass: HomeAssistant, + mock_get_setting_values: dict[str, dict[str, str]], + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that an invalid string count value is handled correctly.""" + mock_get_setting_values["devices:local"].update({"Properties:StringCnt": "invalid"}) + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # ensure no shadow management switch entities were registered + assert [ + name + for name, _ in entity_registry.entities.items() + if name.startswith("switch.scb_shadow_management_dc_string_") + ] == [] + + +@pytest.mark.parametrize( + ("shadow_mgmt", "string"), + [ + ("0", (STATE_OFF, STATE_OFF)), + ("1", (STATE_ON, STATE_OFF)), + ("2", (STATE_OFF, STATE_ON)), + ("3", (STATE_ON, STATE_ON)), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_shadow_management_switch_state( + hass: HomeAssistant, + mock_get_setting_values: dict[str, dict[str, str]], + mock_config_entry: MockConfigEntry, + shadow_mgmt: str, + string: tuple[str, str], +) -> None: + """Test that the state of the shadow management switch is correct.""" + mock_get_setting_values["devices:local"].update( + {"Properties:StringCnt": "2", "Generator:ShadowMgmt:Enable": shadow_mgmt} + ) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=300)) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("switch.scb_shadow_management_dc_string_1") + assert state is not None + assert state.state == string[0] + + state = hass.states.get("switch.scb_shadow_management_dc_string_2") + assert state is not None + assert state.state == string[1] + + +@pytest.mark.parametrize( + ("initial_shadow_mgmt", "dc_string", "service", "shadow_mgmt"), + [ + ("0", 1, SERVICE_TURN_ON, "1"), + ("0", 2, SERVICE_TURN_ON, "2"), + ("2", 1, SERVICE_TURN_ON, "3"), + ("1", 2, SERVICE_TURN_ON, "3"), + ("1", 1, SERVICE_TURN_OFF, "0"), + ("2", 2, SERVICE_TURN_OFF, "0"), + ("3", 1, SERVICE_TURN_OFF, "2"), + ("3", 2, SERVICE_TURN_OFF, "1"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_shadow_management_switch_action( + hass: HomeAssistant, + mock_get_setting_values: dict[str, dict[str, str]], + mock_plenticore_client: Mock, + mock_config_entry: MockConfigEntry, + initial_shadow_mgmt: str, + dc_string: int, + service: str, + shadow_mgmt: str, +) -> None: + """Test that the shadow management can be switch on/off.""" + mock_get_setting_values["devices:local"].update( + { + "Properties:StringCnt": "2", + "Generator:ShadowMgmt:Enable": initial_shadow_mgmt, + } + ) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=300)) + await hass.async_block_till_done(wait_background_tasks=True) + + await hass.services.async_call( + SWITCH_DOMAIN, + service, + target={ATTR_ENTITY_ID: f"switch.scb_shadow_management_dc_string_{dc_string}"}, + blocking=True, + ) + + mock_plenticore_client.set_setting_values.assert_called_with( + "devices:local", {"Generator:ShadowMgmt:Enable": shadow_mgmt} + ) diff --git a/tests/components/lcn/conftest.py b/tests/components/lcn/conftest.py index e588cc7b952103..0282e970fabe54 100644 --- a/tests/components/lcn/conftest.py +++ b/tests/components/lcn/conftest.py @@ -5,8 +5,8 @@ from unittest.mock import AsyncMock, Mock, patch import pypck -import pypck.module -from pypck.module import GroupConnection, ModuleConnection +from pypck import lcn_defs +from pypck.module import GroupConnection, ModuleConnection, Serials import pytest from homeassistant.components.lcn import PchkConnectionManager @@ -25,16 +25,28 @@ class MockModuleConnection(ModuleConnection): """Fake a LCN module connection.""" - status_request_handler = AsyncMock() - activate_status_request_handler = AsyncMock() - cancel_status_request_handler = AsyncMock() request_name = AsyncMock(return_value="TestModule") + request_serials = AsyncMock( + return_value=Serials( + hardware_serial=0x1A20A1234, + manu=0x01, + software_serial=0x190B11, + hardware_type=lcn_defs.HardwareType.UPP, + ) + ) send_command = AsyncMock(return_value=True) + request_status_output = AsyncMock() + request_status_relays = AsyncMock() + request_status_motor_position = AsyncMock() + request_status_binary_sensors = AsyncMock() + request_status_variable = AsyncMock() + request_status_led_and_logic_ops = AsyncMock() + request_status_locked_keys = AsyncMock() def __init__(self, *args: Any, **kwargs: Any) -> None: """Construct ModuleConnection instance.""" super().__init__(*args, **kwargs) - self.serials_request_handler.serial_known.set() + self._serials_known.set() class MockGroupConnection(GroupConnection): @@ -55,14 +67,10 @@ async def async_connect(self, timeout: int = 30) -> None: async def async_close(self) -> None: """Mock closing a connection to PCHK.""" - def get_address_conn(self, addr, request_serials=False): - """Get LCN address connection.""" - return super().get_address_conn(addr, request_serials) - @patch.object(pypck.connection, "ModuleConnection", MockModuleConnection) - def get_module_conn(self, addr, request_serials=False): + def get_module_conn(self, addr): """Get LCN module connection.""" - return super().get_module_conn(addr, request_serials) + return super().get_module_conn(addr) @patch.object(pypck.connection, "GroupConnection", MockGroupConnection) def get_group_conn(self, addr): diff --git a/tests/components/lcn/snapshots/test_cover.ambr b/tests/components/lcn/snapshots/test_cover.ambr index b5d02b8b43b9d8..f59393a8e91536 100644 --- a/tests/components/lcn/snapshots/test_cover.ambr +++ b/tests/components/lcn/snapshots/test_cover.ambr @@ -46,7 +46,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'open', + 'state': 'closed', }) # --- # name: test_setup_lcn_cover[cover.testmodule_cover_relays-entry] @@ -96,7 +96,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'open', + 'state': 'closed', }) # --- # name: test_setup_lcn_cover[cover.testmodule_cover_relays_bs4-entry] @@ -146,7 +146,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'open', + 'state': 'closed', }) # --- # name: test_setup_lcn_cover[cover.testmodule_cover_relays_module-entry] @@ -196,6 +196,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'open', + 'state': 'closed', }) # --- diff --git a/tests/components/lcn/snapshots/test_init.ambr b/tests/components/lcn/snapshots/test_init.ambr index 8d7a858cf16736..f60044be6c1836 100644 --- a/tests/components/lcn/snapshots/test_init.ambr +++ b/tests/components/lcn/snapshots/test_init.ambr @@ -9,10 +9,10 @@ 7, False, ), - 'hardware_serial': -1, - 'hardware_type': -1, + 'hardware_serial': 7013536308, + 'hardware_type': 11, 'name': 'TestModule', - 'software_serial': -1, + 'software_serial': 1641233, }), ]), 'dim_mode': 'STEPS200', @@ -50,10 +50,10 @@ 7, False, ), - 'hardware_serial': -1, - 'hardware_type': -1, + 'hardware_serial': 7013536308, + 'hardware_type': 11, 'name': 'TestModule', - 'software_serial': -1, + 'software_serial': 1641233, }), ]), 'dim_mode': 'STEPS200', @@ -143,10 +143,10 @@ 7, False, ), - 'hardware_serial': -1, - 'hardware_type': -1, + 'hardware_serial': 7013536308, + 'hardware_type': 11, 'name': 'TestModule', - 'software_serial': -1, + 'software_serial': 1641233, }), ]), 'dim_mode': 'STEPS200', diff --git a/tests/components/lcn/test_climate.py b/tests/components/lcn/test_climate.py index ceb6f9524d1b9a..e44a620e33c978 100644 --- a/tests/components/lcn/test_climate.py +++ b/tests/components/lcn/test_climate.py @@ -52,8 +52,15 @@ async def test_set_hvac_mode_heat(hass: HomeAssistant, entry: MockConfigEntry) - await init_integration(hass, entry) with patch.object(MockModuleConnection, "lock_regulator") as lock_regulator: - state = hass.states.get("climate.testmodule_climate1") - state.state = HVACMode.OFF + await hass.services.async_call( + DOMAIN_CLIMATE, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.testmodule_climate1", + ATTR_HVAC_MODE: HVACMode.OFF, + }, + blocking=True, + ) # command failed lock_regulator.return_value = False diff --git a/tests/components/lcn/test_cover.py b/tests/components/lcn/test_cover.py index 1ac4ea6f664047..6c8ed622ad05f5 100644 --- a/tests/components/lcn/test_cover.py +++ b/tests/components/lcn/test_cover.py @@ -63,7 +63,8 @@ async def test_outputs_open(hass: HomeAssistant, entry: MockConfigEntry) -> None MockModuleConnection, "control_motor_outputs" ) as control_motor_outputs: state = hass.states.get(COVER_OUTPUTS) - state.state = CoverState.CLOSED + assert state is not None + assert state.state == CoverState.CLOSED # command failed control_motor_outputs.return_value = False @@ -110,8 +111,12 @@ async def test_outputs_close(hass: HomeAssistant, entry: MockConfigEntry) -> Non with patch.object( MockModuleConnection, "control_motor_outputs" ) as control_motor_outputs: - state = hass.states.get(COVER_OUTPUTS) - state.state = CoverState.OPEN + await hass.services.async_call( + DOMAIN_COVER, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: COVER_OUTPUTS}, + blocking=True, + ) # command failed control_motor_outputs.return_value = False @@ -158,8 +163,12 @@ async def test_outputs_stop(hass: HomeAssistant, entry: MockConfigEntry) -> None with patch.object( MockModuleConnection, "control_motor_outputs" ) as control_motor_outputs: - state = hass.states.get(COVER_OUTPUTS) - state.state = CoverState.CLOSING + await hass.services.async_call( + DOMAIN_COVER, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: COVER_OUTPUTS}, + blocking=True, + ) # command failed control_motor_outputs.return_value = False @@ -203,7 +212,8 @@ async def test_relays_open(hass: HomeAssistant, entry: MockConfigEntry) -> None: MockModuleConnection, "control_motor_relays" ) as control_motor_relays: state = hass.states.get(COVER_RELAYS) - state.state = CoverState.CLOSED + assert state is not None + assert state.state == CoverState.CLOSED # command failed control_motor_relays.return_value = False @@ -250,8 +260,12 @@ async def test_relays_close(hass: HomeAssistant, entry: MockConfigEntry) -> None with patch.object( MockModuleConnection, "control_motor_relays" ) as control_motor_relays: - state = hass.states.get(COVER_RELAYS) - state.state = CoverState.OPEN + await hass.services.async_call( + DOMAIN_COVER, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: COVER_RELAYS}, + blocking=True, + ) # command failed control_motor_relays.return_value = False @@ -298,8 +312,12 @@ async def test_relays_stop(hass: HomeAssistant, entry: MockConfigEntry) -> None: with patch.object( MockModuleConnection, "control_motor_relays" ) as control_motor_relays: - state = hass.states.get(COVER_RELAYS) - state.state = CoverState.CLOSING + await hass.services.async_call( + DOMAIN_COVER, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: COVER_RELAYS}, + blocking=True, + ) # command failed control_motor_relays.return_value = False @@ -360,7 +378,8 @@ async def test_relays_set_position( MockModuleConnection, "control_motor_relays_position" ) as control_motor_relays_position: state = hass.states.get(entity_id) - state.state = CoverState.CLOSED + assert state is not None + assert state.state == CoverState.CLOSED # command failed control_motor_relays_position.return_value = False diff --git a/tests/components/lcn/test_light.py b/tests/components/lcn/test_light.py index b13e18bbbd1a65..0d1bf2619bb3bb 100644 --- a/tests/components/lcn/test_light.py +++ b/tests/components/lcn/test_light.py @@ -209,8 +209,12 @@ async def test_relay_turn_off(hass: HomeAssistant, entry: MockConfigEntry) -> No states = [RelayStateModifier.NOCHANGE] * 8 states[0] = RelayStateModifier.OFF - state = hass.states.get(LIGHT_RELAY1) - state.state = STATE_ON + await hass.services.async_call( + DOMAIN_LIGHT, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: LIGHT_RELAY1}, + blocking=True, + ) # command failed control_relays.return_value = False diff --git a/tests/components/lcn/test_switch.py b/tests/components/lcn/test_switch.py index 0c0067c88751b4..9f314efe6c4bb1 100644 --- a/tests/components/lcn/test_switch.py +++ b/tests/components/lcn/test_switch.py @@ -93,8 +93,12 @@ async def test_output_turn_off(hass: HomeAssistant, entry: MockConfigEntry) -> N await init_integration(hass, entry) with patch.object(MockModuleConnection, "dim_output") as dim_output: - state = hass.states.get(SWITCH_OUTPUT1) - state.state = STATE_ON + await hass.services.async_call( + DOMAIN_SWITCH, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: SWITCH_OUTPUT1}, + blocking=True, + ) # command failed dim_output.return_value = False @@ -176,8 +180,12 @@ async def test_relay_turn_off(hass: HomeAssistant, entry: MockConfigEntry) -> No states = [RelayStateModifier.NOCHANGE] * 8 states[0] = RelayStateModifier.OFF - state = hass.states.get(SWITCH_RELAY1) - state.state = STATE_ON + await hass.services.async_call( + DOMAIN_SWITCH, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: SWITCH_RELAY1}, + blocking=True, + ) # command failed control_relays.return_value = False @@ -257,8 +265,12 @@ async def test_regulatorlock_turn_off( await init_integration(hass, entry) with patch.object(MockModuleConnection, "lock_regulator") as lock_regulator: - state = hass.states.get(SWITCH_REGULATOR1) - state.state = STATE_ON + await hass.services.async_call( + DOMAIN_SWITCH, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: SWITCH_REGULATOR1}, + blocking=True, + ) # command failed lock_regulator.return_value = False @@ -340,8 +352,12 @@ async def test_keylock_turn_off(hass: HomeAssistant, entry: MockConfigEntry) -> states = [KeyLockStateModifier.NOCHANGE] * 8 states[0] = KeyLockStateModifier.OFF - state = hass.states.get(SWITCH_KEYLOCKK1) - state.state = STATE_ON + await hass.services.async_call( + DOMAIN_SWITCH, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: SWITCH_KEYLOCKK1}, + blocking=True, + ) # command failed lock_keys.return_value = False