diff --git a/homeassistant/components/aladdin_connect/quality_scale.yaml b/homeassistant/components/aladdin_connect/quality_scale.yaml index d857f1dcdc2e0..61bd6fc3e424a 100644 --- a/homeassistant/components/aladdin_connect/quality_scale.yaml +++ b/homeassistant/components/aladdin_connect/quality_scale.yaml @@ -37,9 +37,7 @@ rules: log-when-unavailable: todo parallel-updates: todo reauthentication-flow: done - test-coverage: - status: todo - comment: Platform tests for cover and sensor need to be implemented to reach 95% coverage. + test-coverage: done # Gold devices: done diff --git a/homeassistant/components/bsblan/manifest.json b/homeassistant/components/bsblan/manifest.json index 2c597c97fd58a..fc807c2b5ee8c 100644 --- a/homeassistant/components/bsblan/manifest.json +++ b/homeassistant/components/bsblan/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["bsblan"], "quality_scale": "silver", - "requirements": ["python-bsblan==5.0.1"], + "requirements": ["python-bsblan==5.1.0"], "zeroconf": [ { "name": "bsb-lan*", diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 60417a3dd6521..d0304e3f34d07 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -266,6 +266,8 @@ def should_compress(content_type: str, path: str | None = None) -> bool: """Return if we should compress a response.""" if path is not None and NO_COMPRESS.match(path): return False + if content_type.startswith("text/event-stream"): + return False if content_type.startswith("image/"): return "svg" in content_type if content_type.startswith("application/"): diff --git a/homeassistant/components/iron_os/update.py b/homeassistant/components/iron_os/update.py index fba60a8ddafcf..ca7e758106744 100644 --- a/homeassistant/components/iron_os/update.py +++ b/homeassistant/components/iron_os/update.py @@ -9,6 +9,7 @@ UpdateEntityDescription, UpdateEntityFeature, ) +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -22,6 +23,7 @@ UPDATE_DESCRIPTION = UpdateEntityDescription( key="firmware", device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.DIAGNOSTIC, ) diff --git a/homeassistant/components/liebherr/quality_scale.yaml b/homeassistant/components/liebherr/quality_scale.yaml index 4656c2d9e7d03..befd61046e4c0 100644 --- a/homeassistant/components/liebherr/quality_scale.yaml +++ b/homeassistant/components/liebherr/quality_scale.yaml @@ -47,7 +47,7 @@ rules: comment: Cloud API does not require updating entry data from network discovery. discovery: done docs-data-update: done - docs-examples: todo + docs-examples: done docs-known-limitations: done docs-supported-devices: done docs-supported-functions: done diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index a463b123fb6ae..ca36aa5cee979 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -285,9 +285,9 @@ async def send_device_command( self, command: ClusterCommand, **kwargs: Any, - ) -> None: + ) -> Any: """Send device command on the primary attribute's endpoint.""" - await self.matter_client.send_device_command( + return await self.matter_client.send_device_command( node_id=self._endpoint.node.node_id, endpoint_id=self._endpoint.endpoint_id, command=command, diff --git a/homeassistant/components/matter/vacuum.py b/homeassistant/components/matter/vacuum.py index 93922fde0f6f3..6bb0f3f022188 100644 --- a/homeassistant/components/matter/vacuum.py +++ b/homeassistant/components/matter/vacuum.py @@ -10,6 +10,7 @@ from matter_server.client.models import device_types from homeassistant.components.vacuum import ( + Segment, StateVacuumEntity, StateVacuumEntityDescription, VacuumActivity, @@ -70,6 +71,7 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): """Representation of a Matter Vacuum cleaner entity.""" _last_accepted_commands: list[int] | None = None + _last_service_area_feature_map: int | None = None _supported_run_modes: ( dict[int, clusters.RvcRunMode.Structs.ModeOptionStruct] | None ) = None @@ -136,6 +138,16 @@ async def async_start(self) -> None: "No supported run mode found to start the vacuum cleaner." ) + # Reset selected areas to an unconstrained selection to ensure start + # performs a full clean and does not reuse a previous area-targeted + # selection. + if VacuumEntityFeature.CLEAN_AREA in self.supported_features: + # Matter ServiceArea: an empty NewAreas list means unconstrained + # operation (full clean). + await self.send_device_command( + clusters.ServiceArea.Commands.SelectAreas(newAreas=[]) + ) + await self.send_device_command( clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode) ) @@ -144,6 +156,66 @@ async def async_pause(self) -> None: """Pause the cleaning task.""" await self.send_device_command(clusters.RvcOperationalState.Commands.Pause()) + @property + def _current_segments(self) -> dict[str, Segment]: + """Return the current cleanable segments reported by the device.""" + supported_areas: list[clusters.ServiceArea.Structs.AreaStruct] = ( + self.get_matter_attribute_value( + clusters.ServiceArea.Attributes.SupportedAreas + ) + ) + + segments: dict[str, Segment] = {} + for area in supported_areas: + area_name = None + if area.areaInfo and area.areaInfo.locationInfo: + area_name = area.areaInfo.locationInfo.locationName + + if area_name: + segment_id = str(area.areaID) + segments[segment_id] = Segment(id=segment_id, name=area_name) + + return segments + + async def async_get_segments(self) -> list[Segment]: + """Get the segments that can be cleaned. + + Returns a list of segments containing their ids and names. + """ + return list(self._current_segments.values()) + + async def async_clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None: + """Clean the specified segments. + + Args: + segment_ids: List of segment IDs to clean. + **kwargs: Additional arguments (unused). + + """ + area_ids = [int(segment_id) for segment_id in segment_ids] + + mode = self._get_run_mode_by_tag(ModeTag.CLEANING) + if mode is None: + raise HomeAssistantError( + "No supported run mode found to start the vacuum cleaner." + ) + + response = await self.send_device_command( + clusters.ServiceArea.Commands.SelectAreas(newAreas=area_ids) + ) + + if ( + response + and response.status != clusters.ServiceArea.Enums.SelectAreasStatus.kSuccess + ): + raise HomeAssistantError( + f"Failed to select areas: {response.statusText or response.status.name}" + ) + + await self.send_device_command( + clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode) + ) + @callback def _update_from_device(self) -> None: """Update from device.""" @@ -176,16 +248,34 @@ def _update_from_device(self) -> None: state = VacuumActivity.CLEANING self._attr_activity = state + if ( + VacuumEntityFeature.CLEAN_AREA in self.supported_features + and self.registry_entry is not None + and (last_seen_segments := self.last_seen_segments) is not None + and self._current_segments != {s.id: s for s in last_seen_segments} + ): + self.async_create_segments_issue() + @callback def _calculate_features(self) -> None: """Calculate features for HA Vacuum platform.""" accepted_operational_commands: list[int] = self.get_matter_attribute_value( clusters.RvcOperationalState.Attributes.AcceptedCommandList ) - # in principle the feature set should not change, except for the accepted commands - if self._last_accepted_commands == accepted_operational_commands: + service_area_feature_map: int | None = self.get_matter_attribute_value( + clusters.ServiceArea.Attributes.FeatureMap + ) + + # In principle the feature set should not change, except for accepted + # commands and service area feature map. + if ( + self._last_accepted_commands == accepted_operational_commands + and self._last_service_area_feature_map == service_area_feature_map + ): return + self._last_accepted_commands = accepted_operational_commands + self._last_service_area_feature_map = service_area_feature_map supported_features: VacuumEntityFeature = VacuumEntityFeature(0) supported_features |= VacuumEntityFeature.START supported_features |= VacuumEntityFeature.STATE @@ -212,6 +302,12 @@ def _calculate_features(self) -> None: in accepted_operational_commands ): supported_features |= VacuumEntityFeature.RETURN_HOME + # Check if Map feature is enabled for clean area support + if ( + service_area_feature_map is not None + and service_area_feature_map & clusters.ServiceArea.Bitmaps.Feature.kMaps + ): + supported_features |= VacuumEntityFeature.CLEAN_AREA self._attr_supported_features = supported_features @@ -228,6 +324,10 @@ def _calculate_features(self) -> None: clusters.RvcRunMode.Attributes.CurrentMode, clusters.RvcOperationalState.Attributes.OperationalState, ), + optional_attributes=( + clusters.ServiceArea.Attributes.FeatureMap, + clusters.ServiceArea.Attributes.SupportedAreas, + ), device_type=(device_types.RoboticVacuumCleaner,), allow_none_value=True, ), diff --git a/homeassistant/components/matter/valve.py b/homeassistant/components/matter/valve.py index ce9f16921de47..f2deea97d7fc2 100644 --- a/homeassistant/components/matter/valve.py +++ b/homeassistant/components/matter/valve.py @@ -69,34 +69,37 @@ async def async_set_valve_position(self, position: int) -> None: def _update_from_device(self) -> None: """Update from device.""" self._calculate_features() - current_state: int + self._attr_is_opening = False + self._attr_is_closing = False + + current_state: int | None current_state = self.get_matter_attribute_value( ValveConfigurationAndControl.Attributes.CurrentState ) - target_state: int + target_state: int | None target_state = self.get_matter_attribute_value( ValveConfigurationAndControl.Attributes.TargetState ) - if ( - current_state == ValveStateEnum.kTransitioning - and target_state == ValveStateEnum.kOpen + + if current_state is None: + self._attr_is_closed = None + elif current_state == ValveStateEnum.kTransitioning and ( + target_state == ValveStateEnum.kOpen ): self._attr_is_opening = True - self._attr_is_closing = False - elif ( - current_state == ValveStateEnum.kTransitioning - and target_state == ValveStateEnum.kClosed + self._attr_is_closed = None + elif current_state == ValveStateEnum.kTransitioning and ( + target_state == ValveStateEnum.kClosed ): - self._attr_is_opening = False self._attr_is_closing = True + self._attr_is_closed = None elif current_state == ValveStateEnum.kClosed: - self._attr_is_opening = False - self._attr_is_closing = False self._attr_is_closed = True - else: - self._attr_is_opening = False - self._attr_is_closing = False + elif current_state == ValveStateEnum.kOpen: self._attr_is_closed = False + else: + self._attr_is_closed = None + # handle optional position if self.supported_features & ValveEntityFeature.SET_POSITION: self._attr_current_valve_position = self.get_matter_attribute_value( @@ -145,6 +148,7 @@ def _calculate_features( ValveConfigurationAndControl.Attributes.CurrentState, ValveConfigurationAndControl.Attributes.TargetState, ), + allow_none_value=True, optional_attributes=(ValveConfigurationAndControl.Attributes.CurrentLevel,), device_type=(device_types.WaterValve,), ), diff --git a/homeassistant/components/nintendo_parental_controls/__init__.py b/homeassistant/components/nintendo_parental_controls/__init__.py index c1aa245893153..6efe282871884 100644 --- a/homeassistant/components/nintendo_parental_controls/__init__.py +++ b/homeassistant/components/nintendo_parental_controls/__init__.py @@ -20,11 +20,11 @@ from .services import async_setup_services _PLATFORMS: list[Platform] = [ - Platform.SENSOR, - Platform.TIME, - Platform.SWITCH, Platform.NUMBER, Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + Platform.TIME, ] PLATFORM_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) diff --git a/homeassistant/components/nrgkick/diagnostics.py b/homeassistant/components/nrgkick/diagnostics.py new file mode 100644 index 0000000000000..cf6c1d6407eb9 --- /dev/null +++ b/homeassistant/components/nrgkick/diagnostics.py @@ -0,0 +1,30 @@ +"""Diagnostics support for NRGkick.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .coordinator import NRGkickConfigEntry + +TO_REDACT = { + CONF_PASSWORD, + CONF_USERNAME, +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: NRGkickConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + return async_redact_data( + { + "entry_data": entry.data, + "coordinator_data": asdict(entry.runtime_data.data), + }, + TO_REDACT, + ) diff --git a/homeassistant/components/nrgkick/quality_scale.yaml b/homeassistant/components/nrgkick/quality_scale.yaml index 6c02cc08ed1ff..7bdc82b665bab 100644 --- a/homeassistant/components/nrgkick/quality_scale.yaml +++ b/homeassistant/components/nrgkick/quality_scale.yaml @@ -48,7 +48,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery: done discovery-update-info: done docs-data-update: done diff --git a/homeassistant/components/ntfy/__init__.py b/homeassistant/components/ntfy/__init__.py index e2edc3354f3ef..fc1196ebde764 100644 --- a/homeassistant/components/ntfy/__init__.py +++ b/homeassistant/components/ntfy/__init__.py @@ -11,6 +11,7 @@ NtfyTimeoutError, NtfyUnauthorizedAuthenticationError, ) +from aiontfy.update import UpdateChecker from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL, Platform from homeassistant.core import HomeAssistant @@ -18,14 +19,27 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType +from homeassistant.util.hass_dict import HassKey from .const import DOMAIN -from .coordinator import NtfyConfigEntry, NtfyDataUpdateCoordinator +from .coordinator import ( + NtfyConfigEntry, + NtfyDataUpdateCoordinator, + NtfyLatestReleaseUpdateCoordinator, + NtfyRuntimeData, + NtfyVersionDataUpdateCoordinator, +) from .services import async_setup_services _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.EVENT, Platform.NOTIFY, Platform.SENSOR] +PLATFORMS: list[Platform] = [ + Platform.EVENT, + Platform.NOTIFY, + Platform.SENSOR, + Platform.UPDATE, +] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +NTFY_KEY: HassKey[NtfyLatestReleaseUpdateCoordinator] = HassKey(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -40,6 +54,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: NtfyConfigEntry) -> bool session = async_get_clientsession(hass, entry.data.get(CONF_VERIFY_SSL, True)) ntfy = Ntfy(entry.data[CONF_URL], session, token=entry.data.get(CONF_TOKEN)) + if NTFY_KEY not in hass.data: + update_checker = UpdateChecker(session) + update_coordinator = NtfyLatestReleaseUpdateCoordinator(hass, update_checker) + await update_coordinator.async_request_refresh() + hass.data[NTFY_KEY] = update_coordinator try: await ntfy.account() @@ -69,7 +88,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: NtfyConfigEntry) -> bool coordinator = NtfyDataUpdateCoordinator(hass, entry, ntfy) await coordinator.async_config_entry_first_refresh() - entry.runtime_data = coordinator + + version = NtfyVersionDataUpdateCoordinator(hass, entry, ntfy) + await version.async_config_entry_first_refresh() + + entry.runtime_data = NtfyRuntimeData(coordinator, version) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/ntfy/const.py b/homeassistant/components/ntfy/const.py index 5fb500917d67c..753a46bdae793 100644 --- a/homeassistant/components/ntfy/const.py +++ b/homeassistant/components/ntfy/const.py @@ -3,7 +3,7 @@ from typing import Final DOMAIN = "ntfy" -DEFAULT_URL: Final = "https://ntfy.sh" +DEFAULT_URL: Final = "https://ntfy.sh/" CONF_TOPIC = "topic" CONF_PRIORITY = "filter_priority" diff --git a/homeassistant/components/ntfy/coordinator.py b/homeassistant/components/ntfy/coordinator.py index a52f1b06f41ad..2421b6b8061b6 100644 --- a/homeassistant/components/ntfy/coordinator.py +++ b/homeassistant/components/ntfy/coordinator.py @@ -2,16 +2,20 @@ from __future__ import annotations +from abc import abstractmethod +from dataclasses import dataclass from datetime import timedelta import logging -from aiontfy import Account as NtfyAccount, Ntfy +from aiontfy import Account as NtfyAccount, Ntfy, Version from aiontfy.exceptions import ( NtfyConnectionError, NtfyHTTPError, + NtfyNotFoundPageError, NtfyTimeoutError, NtfyUnauthorizedAuthenticationError, ) +from aiontfy.update import LatestRelease, UpdateChecker, UpdateCheckerError from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -22,13 +26,22 @@ _LOGGER = logging.getLogger(__name__) -type NtfyConfigEntry = ConfigEntry[NtfyDataUpdateCoordinator] +type NtfyConfigEntry = ConfigEntry[NtfyRuntimeData] -class NtfyDataUpdateCoordinator(DataUpdateCoordinator[NtfyAccount]): - """Ntfy data update coordinator.""" +@dataclass +class NtfyRuntimeData: + """Holds ntfy runtime data.""" + + account: NtfyDataUpdateCoordinator + version: NtfyVersionDataUpdateCoordinator + + +class BaseDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): + """Ntfy base coordinator.""" config_entry: NtfyConfigEntry + update_interval: timedelta def __init__( self, hass: HomeAssistant, config_entry: NtfyConfigEntry, ntfy: Ntfy @@ -39,21 +52,19 @@ def __init__( _LOGGER, config_entry=config_entry, name=DOMAIN, - update_interval=timedelta(minutes=15), + update_interval=self.update_interval, ) self.ntfy = ntfy - async def _async_update_data(self) -> NtfyAccount: - """Fetch account data from ntfy.""" + @abstractmethod + async def async_update_data(self) -> _DataT: + """Fetch the latest data from the source.""" + async def _async_update_data(self) -> _DataT: + """Fetch the latest data from the source.""" try: - return await self.ntfy.account() - except NtfyUnauthorizedAuthenticationError as e: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, - translation_key="authentication_error", - ) from e + return await self.async_update_data() except NtfyHTTPError as e: _LOGGER.debug("Error %s: %s [%s]", e.code, e.error, e.link) raise UpdateFailed( @@ -72,3 +83,62 @@ async def _async_update_data(self) -> NtfyAccount: translation_domain=DOMAIN, translation_key="timeout_error", ) from e + + +class NtfyDataUpdateCoordinator(BaseDataUpdateCoordinator[NtfyAccount]): + """Ntfy data update coordinator.""" + + update_interval = timedelta(minutes=15) + + async def async_update_data(self) -> NtfyAccount: + """Fetch account data from ntfy.""" + + try: + return await self.ntfy.account() + except NtfyUnauthorizedAuthenticationError as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_error", + ) from e + + +class NtfyVersionDataUpdateCoordinator(BaseDataUpdateCoordinator[Version | None]): + """Ntfy data update coordinator.""" + + update_interval = timedelta(hours=3) + + async def async_update_data(self) -> Version | None: + """Fetch version data from ntfy.""" + try: + version = await self.ntfy.version() + except NtfyUnauthorizedAuthenticationError, NtfyNotFoundPageError: + # /v1/version endpoint is only accessible to admins and + # available in ntfy since version 2.17.0 + return None + return version + + +class NtfyLatestReleaseUpdateCoordinator(DataUpdateCoordinator[LatestRelease]): + """Ntfy latest release update coordinator.""" + + def __init__(self, hass: HomeAssistant, update_checker: UpdateChecker) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=None, + name=DOMAIN, + update_interval=timedelta(hours=3), + ) + self.update_checker = update_checker + + async def _async_update_data(self) -> LatestRelease: + """Fetch latest release data.""" + + try: + return await self.update_checker.latest_release() + except UpdateCheckerError as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_check_failed", + ) from e diff --git a/homeassistant/components/ntfy/entity.py b/homeassistant/components/ntfy/entity.py index d03d953799f05..856303cd60dd5 100644 --- a/homeassistant/components/ntfy/entity.py +++ b/homeassistant/components/ntfy/entity.py @@ -7,10 +7,11 @@ from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_URL from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_TOPIC, DOMAIN -from .coordinator import NtfyConfigEntry +from .coordinator import BaseDataUpdateCoordinator, NtfyConfigEntry class NtfyBaseEntity(Entity): @@ -38,6 +39,29 @@ def __init__( identifiers={(DOMAIN, f"{config_entry.entry_id}_{subentry.subentry_id}")}, via_device=(DOMAIN, config_entry.entry_id), ) - self.ntfy = config_entry.runtime_data.ntfy + self.ntfy = config_entry.runtime_data.account.ntfy self.config_entry = config_entry self.subentry = subentry + + +class NtfyCommonBaseEntity(CoordinatorEntity[BaseDataUpdateCoordinator]): + """Base entity for common entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: BaseDataUpdateCoordinator, + description: EntityDescription, + ) -> None: + """Initialize entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + manufacturer="ntfy LLC", + model="ntfy", + configuration_url=URL(coordinator.config_entry.data[CONF_URL]) / "app", + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + ) diff --git a/homeassistant/components/ntfy/sensor.py b/homeassistant/components/ntfy/sensor.py index cb005eb84d8e0..89a30493c1f06 100644 --- a/homeassistant/components/ntfy/sensor.py +++ b/homeassistant/components/ntfy/sensor.py @@ -7,22 +7,19 @@ from enum import StrEnum from aiontfy import Account as NtfyAccount -from yarl import URL from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, ) -from homeassistant.const import CONF_URL, EntityCategory, UnitOfInformation, UnitOfTime +from homeassistant.const import EntityCategory, UnitOfInformation, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN from .coordinator import NtfyConfigEntry, NtfyDataUpdateCoordinator +from .entity import NtfyCommonBaseEntity PARALLEL_UPDATES = 0 @@ -233,38 +230,19 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" - coordinator = config_entry.runtime_data + coordinator = config_entry.runtime_data.account async_add_entities( NtfySensorEntity(coordinator, description) for description in SENSOR_DESCRIPTIONS ) -class NtfySensorEntity(CoordinatorEntity[NtfyDataUpdateCoordinator], SensorEntity): +class NtfySensorEntity(NtfyCommonBaseEntity, SensorEntity): """Representation of a ntfy sensor entity.""" entity_description: NtfySensorEntityDescription coordinator: NtfyDataUpdateCoordinator - _attr_has_entity_name = True - - def __init__( - self, - coordinator: NtfyDataUpdateCoordinator, - description: NtfySensorEntityDescription, - ) -> None: - """Initialize a sensor entity.""" - super().__init__(coordinator) - self.entity_description = description - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - manufacturer="ntfy LLC", - model="ntfy", - configuration_url=URL(coordinator.config_entry.data[CONF_URL]) / "app", - identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, - ) - @property def native_value(self) -> StateType: """Return the state of the sensor.""" diff --git a/homeassistant/components/ntfy/strings.json b/homeassistant/components/ntfy/strings.json index 8f017b6b96d36..689c3194cb31b 100644 --- a/homeassistant/components/ntfy/strings.json +++ b/homeassistant/components/ntfy/strings.json @@ -261,6 +261,11 @@ "supporter": "Supporter" } } + }, + "update": { + "update": { + "name": "ntfy version" + } } }, "exceptions": { @@ -302,6 +307,9 @@ }, "timeout_error": { "message": "Failed to connect to ntfy service due to a connection timeout" + }, + "update_check_failed": { + "message": "Failed to check for latest ntfy update" } }, "issues": { diff --git a/homeassistant/components/ntfy/update.py b/homeassistant/components/ntfy/update.py new file mode 100644 index 0000000000000..039be5a509641 --- /dev/null +++ b/homeassistant/components/ntfy/update.py @@ -0,0 +1,116 @@ +"""Update platform for the ntfy integration.""" + +from __future__ import annotations + +from enum import StrEnum + +from homeassistant.components.update import ( + UpdateEntity, + UpdateEntityDescription, + UpdateEntityFeature, +) +from homeassistant.const import CONF_URL, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import NTFY_KEY +from .const import DEFAULT_URL +from .coordinator import ( + NtfyConfigEntry, + NtfyLatestReleaseUpdateCoordinator, + NtfyVersionDataUpdateCoordinator, +) +from .entity import NtfyCommonBaseEntity + +PARALLEL_UPDATES = 0 + + +class NtfyUpdate(StrEnum): + """Ntfy update.""" + + UPDATE = "update" + + +DESCRIPTION = UpdateEntityDescription( + key=NtfyUpdate.UPDATE, + translation_key=NtfyUpdate.UPDATE, + entity_category=EntityCategory.DIAGNOSTIC, +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NtfyConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up update platform.""" + if ( + entry.data[CONF_URL] != DEFAULT_URL + and (version_coordinator := entry.runtime_data.version).data is not None + ): + update_coordinator = hass.data[NTFY_KEY] + async_add_entities( + [NtfyUpdateEntity(version_coordinator, update_coordinator, DESCRIPTION)] + ) + + +class NtfyUpdateEntity(NtfyCommonBaseEntity, UpdateEntity): + """Representation of an update entity.""" + + _attr_supported_features = UpdateEntityFeature.RELEASE_NOTES + coordinator: NtfyVersionDataUpdateCoordinator + + def __init__( + self, + coordinator: NtfyVersionDataUpdateCoordinator, + update_checker: NtfyLatestReleaseUpdateCoordinator, + description: EntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator, description) + self.update_checker = update_checker + if self._attr_device_info and self.installed_version: + self._attr_device_info.update({"sw_version": self.installed_version}) + + @property + def installed_version(self) -> str | None: + """Current version.""" + return self.coordinator.data.version if self.coordinator.data else None + + @property + def title(self) -> str | None: + """Title of the release.""" + + return f"ntfy {self.update_checker.data.name}" + + @property + def release_url(self) -> str | None: + """URL to the full release notes.""" + + return self.update_checker.data.html_url + + @property + def latest_version(self) -> str | None: + """Latest version.""" + + return self.update_checker.data.tag_name.removeprefix("v") + + async def async_release_notes(self) -> str | None: + """Return the release notes.""" + return self.update_checker.data.body + + async def async_added_to_hass(self) -> None: + """When entity is added to hass. + + Register extra update listener for the update checker coordinator. + """ + await super().async_added_to_hass() + self.async_on_remove( + self.update_checker.async_add_listener(self._handle_coordinator_update) + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.update_checker.last_update_success diff --git a/homeassistant/components/portainer/__init__.py b/homeassistant/components/portainer/__init__.py index d74c35dcdb975..9d6f3524605ff 100644 --- a/homeassistant/components/portainer/__init__.py +++ b/homeassistant/components/portainer/__init__.py @@ -29,9 +29,9 @@ _PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.SENSOR, Platform.SWITCH, - Platform.BUTTON, ] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) diff --git a/homeassistant/components/renault/services.py b/homeassistant/components/renault/services.py index df85ad57f668a..03531924533c6 100644 --- a/homeassistant/components/renault/services.py +++ b/homeassistant/components/renault/services.py @@ -92,17 +92,6 @@ } ) -SERVICE_AC_CANCEL = "ac_cancel" -SERVICE_AC_START = "ac_start" -SERVICE_CHARGE_SET_SCHEDULES = "charge_set_schedules" -SERVICE_AC_SET_SCHEDULES = "ac_set_schedules" -SERVICES = [ - SERVICE_AC_CANCEL, - SERVICE_AC_START, - SERVICE_CHARGE_SET_SCHEDULES, - SERVICE_AC_SET_SCHEDULES, -] - async def ac_cancel(service_call: ServiceCall) -> None: """Cancel A/C.""" @@ -197,25 +186,25 @@ def async_setup_services(hass: HomeAssistant) -> None: hass.services.async_register( DOMAIN, - SERVICE_AC_CANCEL, + "ac_cancel", ac_cancel, schema=SERVICE_VEHICLE_SCHEMA, ) hass.services.async_register( DOMAIN, - SERVICE_AC_START, + "ac_start", ac_start, schema=SERVICE_AC_START_SCHEMA, ) hass.services.async_register( DOMAIN, - SERVICE_CHARGE_SET_SCHEDULES, + "charge_set_schedules", charge_set_schedules, schema=SERVICE_CHARGE_SET_SCHEDULES_SCHEMA, ) hass.services.async_register( DOMAIN, - SERVICE_AC_SET_SCHEDULES, + "ac_set_schedules", ac_set_schedules, schema=SERVICE_AC_SET_SCHEDULES_SCHEMA, ) diff --git a/homeassistant/components/route_b_smart_meter/coordinator.py b/homeassistant/components/route_b_smart_meter/coordinator.py index 7cfa2810b5b0f..9ca9708791fdb 100644 --- a/homeassistant/components/route_b_smart_meter/coordinator.py +++ b/homeassistant/components/route_b_smart_meter/coordinator.py @@ -2,6 +2,7 @@ from dataclasses import dataclass import logging +import time from momonga import Momonga, MomongaError @@ -28,9 +29,20 @@ class BRouteData: type BRouteConfigEntry = ConfigEntry[BRouteUpdateCoordinator] +@dataclass +class BRouteDeviceInfo: + """Static device information fetched once at setup.""" + + serial_number: str | None = None + manufacturer_code: str | None = None + echonet_version: str | None = None + + class BRouteUpdateCoordinator(DataUpdateCoordinator[BRouteData]): """The B Route update coordinator.""" + device_info_data: BRouteDeviceInfo + def __init__( self, hass: HomeAssistant, @@ -40,9 +52,9 @@ def __init__( self.device = entry.data[CONF_DEVICE] self.bid = entry.data[CONF_ID] - password = entry.data[CONF_PASSWORD] + self._password = entry.data[CONF_PASSWORD] - self.api = Momonga(dev=self.device, rbid=self.bid, pwd=password) + self.api = Momonga(dev=self.device, rbid=self.bid, pwd=self._password) super().__init__( hass, @@ -52,10 +64,34 @@ def __init__( update_interval=DEFAULT_SCAN_INTERVAL, ) + self.device_info_data = BRouteDeviceInfo() + async def _async_setup(self) -> None: - await self.hass.async_add_executor_job( - self.api.open, - ) + def fetch() -> None: + self.api.open() + self._fetch_device_info() + + await self.hass.async_add_executor_job(fetch) + + def _fetch_device_info(self) -> None: + """Fetch static device information from the smart meter.""" + try: + self.device_info_data.serial_number = self.api.get_serial_number() + except MomongaError: + _LOGGER.debug("Failed to fetch serial number", exc_info=True) + + time.sleep(self.api.internal_xmit_interval) + try: + raw = self.api.get_manufacturer_code() + self.device_info_data.manufacturer_code = raw.hex().upper() + except MomongaError: + _LOGGER.debug("Failed to fetch manufacturer code", exc_info=True) + + time.sleep(self.api.internal_xmit_interval) + try: + self.device_info_data.echonet_version = self.api.get_standard_version() + except MomongaError: + _LOGGER.debug("Failed to fetch ECHONET Lite version", exc_info=True) def _get_data(self) -> BRouteData: """Get the data from API.""" diff --git a/homeassistant/components/route_b_smart_meter/sensor.py b/homeassistant/components/route_b_smart_meter/sensor.py index c8034528f5ac8..c85a633f29c4f 100644 --- a/homeassistant/components/route_b_smart_meter/sensor.py +++ b/homeassistant/components/route_b_smart_meter/sensor.py @@ -2,6 +2,7 @@ from collections.abc import Callable from dataclasses import dataclass +from typing import Literal from homeassistant.components.sensor import ( SensorDeviceClass, @@ -69,6 +70,27 @@ class SensorEntityDescriptionWithValueAccessor(SensorEntityDescription): ), ) +_DEVICE_INFO_MAPPING: dict[ + Literal["manufacturer", "serial_number", "sw_version"], + Callable[[BRouteUpdateCoordinator], str | None], +] = { + "manufacturer": lambda coordinator: coordinator.device_info_data.manufacturer_code, + "serial_number": lambda coordinator: coordinator.device_info_data.serial_number, + "sw_version": lambda coordinator: coordinator.device_info_data.echonet_version, +} + + +def _build_device_info(coordinator: BRouteUpdateCoordinator) -> DeviceInfo: + """Build device information from coordinator data.""" + device = DeviceInfo( + identifiers={(DOMAIN, coordinator.bid)}, + name=f"Route B Smart Meter {coordinator.bid}", + ) + for key, fn in _DEVICE_INFO_MAPPING.items(): + if (value := fn(coordinator)) is not None: + device[key] = value + return device + async def async_setup_entry( hass: HomeAssistant, @@ -98,10 +120,7 @@ def __init__( super().__init__(coordinator) self.entity_description: SensorEntityDescriptionWithValueAccessor = description self._attr_unique_id = f"{coordinator.bid}_{description.key}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.bid)}, - name=f"Route B Smart Meter {coordinator.bid}", - ) + self._attr_device_info = _build_device_info(coordinator) @property def native_value(self) -> StateType: diff --git a/homeassistant/components/smarla/const.py b/homeassistant/components/smarla/const.py index fcb64f1e3156d..7f0572d0ecbe1 100644 --- a/homeassistant/components/smarla/const.py +++ b/homeassistant/components/smarla/const.py @@ -6,7 +6,7 @@ HOST = "https://devices.swing2sleep.de" -PLATFORMS = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, Platform.UPDATE] DEVICE_MODEL_NAME = "Smarla" MANUFACTURER_NAME = "Swing2Sleep" diff --git a/homeassistant/components/smarla/entity.py b/homeassistant/components/smarla/entity.py index 59bc9275f29d7..d63b2bc39e192 100644 --- a/homeassistant/components/smarla/entity.py +++ b/homeassistant/components/smarla/entity.py @@ -31,7 +31,7 @@ class SmarlaBaseEntity(Entity): _attr_has_entity_name = True def __init__(self, federwiege: Federwiege, desc: SmarlaEntityDescription) -> None: - """Initialise the entity.""" + """Initialize the entity.""" self.entity_description = desc self._federwiege = federwiege self._property = federwiege.get_property(desc.service, desc.property) diff --git a/homeassistant/components/smarla/update.py b/homeassistant/components/smarla/update.py new file mode 100644 index 0000000000000..dee4df7a8b303 --- /dev/null +++ b/homeassistant/components/smarla/update.py @@ -0,0 +1,110 @@ +"""Swing2Sleep Smarla Update platform.""" + +from dataclasses import dataclass +from datetime import timedelta +from typing import Any + +from pysmarlaapi import Federwiege +from pysmarlaapi.federwiege.services.classes import Property +from pysmarlaapi.federwiege.services.types import UpdateStatus + +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityDescription, + UpdateEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FederwiegeConfigEntry +from .entity import SmarlaBaseEntity, SmarlaEntityDescription + +SCAN_INTERVAL = timedelta(seconds=300) +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class SmarlaUpdateEntityDescription(SmarlaEntityDescription, UpdateEntityDescription): + """Class describing Swing2Sleep Smarla update entity.""" + + +UPDATE_ENTITY_DESC = SmarlaUpdateEntityDescription( + key="update", + service="info", + property="version", + device_class=UpdateDeviceClass.FIRMWARE, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: FederwiegeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Smarla update entity based on a config entry.""" + federwiege = config_entry.runtime_data + async_add_entities([SmarlaUpdate(federwiege, UPDATE_ENTITY_DESC)], True) + + +class SmarlaUpdate(SmarlaBaseEntity, UpdateEntity): + """Defines an Smarla update entity.""" + + _attr_supported_features = ( + UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS + ) + _attr_should_poll = True + + entity_description: SmarlaUpdateEntityDescription + + _property: Property[str] + _update_property: Property[int] + _update_status_property: Property[UpdateStatus] + + def __init__( + self, federwiege: Federwiege, desc: SmarlaUpdateEntityDescription + ) -> None: + """Initialize the update entity.""" + super().__init__(federwiege, desc) + self._update_property = federwiege.get_property("system", "firmware_update") + self._update_status_property = federwiege.get_property( + "system", "firmware_update_status" + ) + + async def async_update(self) -> None: + """Check for firmware update and update attributes.""" + value = await self._federwiege.check_firmware_update() + if value is None: + self._attr_latest_version = None + self._attr_release_summary = None + return + + target, notes = value + + self._attr_latest_version = target + self._attr_release_summary = notes + + async def async_added_to_hass(self) -> None: + """Run when this Entity has been added to HA.""" + await super().async_added_to_hass() + await self._update_status_property.add_listener(self.on_change) + + async def async_will_remove_from_hass(self) -> None: + """Entity being removed from hass.""" + await super().async_will_remove_from_hass() + await self._update_status_property.remove_listener(self.on_change) + + @property + def in_progress(self) -> bool | None: + """Return if an update is in progress.""" + status = self._update_status_property.get() + return status not in (None, UpdateStatus.IDLE, UpdateStatus.FAILED) + + @property + def installed_version(self) -> str | None: + """Return the current installed version.""" + return self._property.get() + + def install(self, version: str | None, backup: bool, **kwargs: Any) -> None: + """Install latest update.""" + self._update_property.set(1) diff --git a/homeassistant/components/togrill/__init__.py b/homeassistant/components/togrill/__init__.py index f7e6568575e75..280a23ba53830 100644 --- a/homeassistant/components/togrill/__init__.py +++ b/homeassistant/components/togrill/__init__.py @@ -10,9 +10,9 @@ _PLATFORMS: list[Platform] = [ Platform.EVENT, + Platform.NUMBER, Platform.SELECT, Platform.SENSOR, - Platform.NUMBER, ] diff --git a/homeassistant/components/tuya/alarm_control_panel.py b/homeassistant/components/tuya/alarm_control_panel.py index 9a317a50e859b..42e3cb0c5ce53 100644 --- a/homeassistant/components/tuya/alarm_control_panel.py +++ b/homeassistant/components/tuya/alarm_control_panel.py @@ -5,6 +5,12 @@ from base64 import b64decode from typing import Any +from tuya_device_handlers.device_wrapper.base import DeviceWrapper +from tuya_device_handlers.device_wrapper.common import ( + DPCodeEnumWrapper, + DPCodeRawWrapper, +) +from tuya_device_handlers.type_information import EnumTypeInformation from tuya_sharing import CustomerDevice, Manager from homeassistant.components.alarm_control_panel import ( @@ -20,8 +26,6 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -from .models import DeviceWrapper, DPCodeEnumWrapper, DPCodeRawWrapper -from .type_information import EnumTypeInformation ALARM: dict[DeviceCategory, tuple[AlarmControlPanelEntityDescription, ...]] = { DeviceCategory.MAL: ( @@ -39,7 +43,7 @@ class _AlarmChangedByWrapper(DPCodeRawWrapper): Decode base64 to utf-16be string, but only if alarm has been triggered. """ - def read_device_status(self, device: CustomerDevice) -> str | None: + def read_device_status(self, device: CustomerDevice) -> str | None: # type: ignore[override] """Read the device status.""" if ( device.status.get(DPCode.MASTER_STATE) != "alarm" diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index 430e9f71b7263..d491e3b39c3f1 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -4,6 +4,12 @@ from dataclasses import dataclass +from tuya_device_handlers.device_wrapper.base import DeviceWrapper +from tuya_device_handlers.device_wrapper.binary_sensor import DPCodeBitmapBitWrapper +from tuya_device_handlers.device_wrapper.common import ( + DPCodeBooleanWrapper, + DPCodeWrapper, +) from tuya_sharing import CustomerDevice, Manager from homeassistant.components.binary_sensor import ( @@ -19,12 +25,6 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -from .models import ( - DeviceWrapper, - DPCodeBitmapBitWrapper, - DPCodeBooleanWrapper, - DPCodeWrapper, -) @dataclass(frozen=True) diff --git a/homeassistant/components/tuya/button.py b/homeassistant/components/tuya/button.py index c28d351c2e8bf..f0ca104d169f6 100644 --- a/homeassistant/components/tuya/button.py +++ b/homeassistant/components/tuya/button.py @@ -2,6 +2,8 @@ from __future__ import annotations +from tuya_device_handlers.device_wrapper.base import DeviceWrapper +from tuya_device_handlers.device_wrapper.common import DPCodeBooleanWrapper from tuya_sharing import CustomerDevice, Manager from homeassistant.components.button import ButtonEntity, ButtonEntityDescription @@ -13,7 +15,6 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -from .models import DeviceWrapper, DPCodeBooleanWrapper BUTTONS: dict[DeviceCategory, tuple[ButtonEntityDescription, ...]] = { DeviceCategory.HXD: ( diff --git a/homeassistant/components/tuya/camera.py b/homeassistant/components/tuya/camera.py index 96eb7c4140289..bb0ed4982a1b9 100644 --- a/homeassistant/components/tuya/camera.py +++ b/homeassistant/components/tuya/camera.py @@ -2,6 +2,8 @@ from __future__ import annotations +from tuya_device_handlers.device_wrapper.base import DeviceWrapper +from tuya_device_handlers.device_wrapper.common import DPCodeBooleanWrapper from tuya_sharing import CustomerDevice, Manager from homeassistant.components import ffmpeg @@ -13,7 +15,6 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -from .models import DeviceWrapper, DPCodeBooleanWrapper CAMERAS: tuple[DeviceCategory, ...] = ( DeviceCategory.DGHSXJ, diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 939b5989a6f72..f8e55a2064882 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -6,6 +6,13 @@ from dataclasses import dataclass from typing import Any, Self +from tuya_device_handlers.device_wrapper.base import DeviceWrapper +from tuya_device_handlers.device_wrapper.common import ( + DPCodeBooleanWrapper, + DPCodeEnumWrapper, + DPCodeIntegerWrapper, +) +from tuya_device_handlers.type_information import EnumTypeInformation from tuya_sharing import CustomerDevice, Manager from homeassistant.components.climate import ( @@ -33,13 +40,6 @@ DPCode, ) from .entity import TuyaEntity -from .models import ( - DeviceWrapper, - DPCodeBooleanWrapper, - DPCodeEnumWrapper, - DPCodeIntegerWrapper, -) -from .type_information import EnumTypeInformation TUYA_HVAC_TO_HA = { "auto": HVACMode.HEAT_COOL, @@ -177,8 +177,10 @@ def read_device_status(self, device: CustomerDevice) -> HVACMode | None: return None return TUYA_HVAC_TO_HA[raw] - def _convert_value_to_raw_value( - self, device: CustomerDevice, value: HVACMode + def _convert_value_to_raw_value( # type: ignore[override] + self, + device: CustomerDevice, + value: HVACMode, ) -> Any: """Convert value to raw value.""" return next( diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index dde6d329e1ad9..aa57cb08d5fed 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -82,18 +82,6 @@ class WorkMode(StrEnum): WHITE = "white" -class DPType(StrEnum): - """Data point types.""" - - BITMAP = "Bitmap" - BOOLEAN = "Boolean" - ENUM = "Enum" - INTEGER = "Integer" - JSON = "Json" - RAW = "Raw" - STRING = "String" - - class DeviceCategory(StrEnum): """Tuya device categories. diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index 1813b6964ca3f..fb9a5610e2516 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -5,6 +5,17 @@ from dataclasses import dataclass from typing import Any +from tuya_device_handlers.device_wrapper.base import DeviceWrapper +from tuya_device_handlers.device_wrapper.common import ( + DPCodeBooleanWrapper, + DPCodeEnumWrapper, + DPCodeIntegerWrapper, +) +from tuya_device_handlers.type_information import ( + EnumTypeInformation, + IntegerTypeInformation, +) +from tuya_device_handlers.utils import RemapHelper from tuya_sharing import CustomerDevice, Manager from homeassistant.components.cover import ( @@ -22,14 +33,6 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -from .models import ( - DeviceWrapper, - DPCodeBooleanWrapper, - DPCodeEnumWrapper, - DPCodeIntegerWrapper, -) -from .type_information import EnumTypeInformation, IntegerTypeInformation -from .util import RemapHelper class _DPCodePercentageMappingWrapper(DPCodeIntegerWrapper): @@ -84,7 +87,7 @@ class _InstructionBooleanWrapper(DPCodeBooleanWrapper): options = ["open", "close"] _ACTION_MAPPINGS = {"open": True, "close": False} - def _convert_value_to_raw_value(self, device: CustomerDevice, value: str) -> bool: + def _convert_value_to_raw_value(self, device: CustomerDevice, value: str) -> bool: # type: ignore[override] return self._ACTION_MAPPINGS[value] @@ -130,7 +133,7 @@ class _IsClosedEnumWrapper(DPCodeEnumWrapper): "fully_open": False, } - def read_device_status(self, device: CustomerDevice) -> bool | None: + def read_device_status(self, device: CustomerDevice) -> bool | None: # type: ignore[override] if (value := super().read_device_status(device)) is None: return None return self._MAPPINGS.get(value) diff --git a/homeassistant/components/tuya/diagnostics.py b/homeassistant/components/tuya/diagnostics.py index 75abb9144276c..ff4b64e67cde3 100644 --- a/homeassistant/components/tuya/diagnostics.py +++ b/homeassistant/components/tuya/diagnostics.py @@ -4,6 +4,7 @@ from typing import Any +from tuya_device_handlers.device_wrapper import DEVICE_WARNINGS from tuya_sharing import CustomerDevice from homeassistant.components.diagnostics import REDACTED @@ -14,7 +15,6 @@ from . import TuyaConfigEntry from .const import DOMAIN, DPCode -from .type_information import DEVICE_WARNINGS _REDACTED_DPCODES = { DPCode.ALARM_MESSAGE, diff --git a/homeassistant/components/tuya/entity.py b/homeassistant/components/tuya/entity.py index 393eb71afe54a..4581552c22632 100644 --- a/homeassistant/components/tuya/entity.py +++ b/homeassistant/components/tuya/entity.py @@ -4,6 +4,7 @@ from typing import Any +from tuya_device_handlers.device_wrapper import DeviceWrapper from tuya_sharing import CustomerDevice, Manager from homeassistant.helpers.device_registry import DeviceInfo @@ -11,7 +12,6 @@ from homeassistant.helpers.entity import Entity from .const import DOMAIN, LOGGER, TUYA_HA_SIGNAL_UPDATE_ENTITY -from .models import DeviceWrapper class TuyaEntity(Entity): diff --git a/homeassistant/components/tuya/event.py b/homeassistant/components/tuya/event.py index 583940f28dbc9..8ede91c26e1ce 100644 --- a/homeassistant/components/tuya/event.py +++ b/homeassistant/components/tuya/event.py @@ -6,6 +6,13 @@ from dataclasses import dataclass from typing import Any +from tuya_device_handlers.device_wrapper.base import DeviceWrapper +from tuya_device_handlers.device_wrapper.common import ( + DPCodeEnumWrapper, + DPCodeRawWrapper, + DPCodeStringWrapper, + DPCodeTypeInformationWrapper, +) from tuya_sharing import CustomerDevice, Manager from homeassistant.components.event import ( @@ -20,19 +27,14 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -from .models import ( - DeviceWrapper, - DPCodeEnumWrapper, - DPCodeRawWrapper, - DPCodeStringWrapper, - DPCodeTypeInformationWrapper, -) class _EventEnumWrapper(DPCodeEnumWrapper): """Wrapper for event enum DP codes.""" - def read_device_status(self, device: CustomerDevice) -> tuple[str, None] | None: + def read_device_status( # type: ignore[override] + self, device: CustomerDevice + ) -> tuple[str, None] | None: """Return the event details.""" if (raw_value := super().read_device_status(device)) is None: return None @@ -67,7 +69,7 @@ def __init__(self, dpcode: str, type_information: Any) -> None: super().__init__(dpcode, type_information) self.options = ["triggered"] - def read_device_status( + def read_device_status( # type: ignore[override] self, device: CustomerDevice ) -> tuple[str, dict[str, Any]] | None: """Return the event attributes for the doorbell picture.""" diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index 7cd16296c9a62..02733972bc2c9 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -4,6 +4,14 @@ from typing import Any +from tuya_device_handlers.device_wrapper.base import DeviceWrapper +from tuya_device_handlers.device_wrapper.common import ( + DPCodeBooleanWrapper, + DPCodeEnumWrapper, + DPCodeIntegerWrapper, +) +from tuya_device_handlers.type_information import IntegerTypeInformation +from tuya_device_handlers.utils import RemapHelper from tuya_sharing import CustomerDevice, Manager from homeassistant.components.fan import ( @@ -23,14 +31,7 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -from .models import ( - DeviceWrapper, - DPCodeBooleanWrapper, - DPCodeEnumWrapper, - DPCodeIntegerWrapper, -) -from .type_information import IntegerTypeInformation -from .util import RemapHelper, get_dpcode +from .util import get_dpcode _DIRECTION_DPCODES = (DPCode.FAN_DIRECTION,) _MODE_DPCODES = (DPCode.FAN_MODE, DPCode.MODE) @@ -82,7 +83,7 @@ def _has_a_valid_dpcode(device: CustomerDevice) -> bool: class _FanSpeedEnumWrapper(DPCodeEnumWrapper): """Wrapper for fan speed DP code (from an enum).""" - def read_device_status(self, device: CustomerDevice) -> int | None: + def read_device_status(self, device: CustomerDevice) -> int | None: # type: ignore[override] """Get the current speed as a percentage.""" if (value := super().read_device_status(device)) is None: return None diff --git a/homeassistant/components/tuya/humidifier.py b/homeassistant/components/tuya/humidifier.py index 0da70a83563f2..4bf085d6b2ee5 100644 --- a/homeassistant/components/tuya/humidifier.py +++ b/homeassistant/components/tuya/humidifier.py @@ -5,6 +5,12 @@ from dataclasses import dataclass from typing import Any +from tuya_device_handlers.device_wrapper.base import DeviceWrapper +from tuya_device_handlers.device_wrapper.common import ( + DPCodeBooleanWrapper, + DPCodeEnumWrapper, + DPCodeIntegerWrapper, +) from tuya_sharing import CustomerDevice, Manager from homeassistant.components.humidifier import ( @@ -20,12 +26,6 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -from .models import ( - DeviceWrapper, - DPCodeBooleanWrapper, - DPCodeEnumWrapper, - DPCodeIntegerWrapper, -) from .util import ActionDPCodeNotFoundError, get_dpcode diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index b28e0c4d4ac44..9c0d0fb538deb 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -7,6 +7,15 @@ import json from typing import Any, cast +from tuya_device_handlers.device_wrapper.base import DeviceWrapper +from tuya_device_handlers.device_wrapper.common import ( + DPCodeBooleanWrapper, + DPCodeEnumWrapper, + DPCodeIntegerWrapper, + DPCodeJsonWrapper, +) +from tuya_device_handlers.type_information import IntegerTypeInformation +from tuya_device_handlers.utils import RemapHelper from tuya_sharing import CustomerDevice, Manager from homeassistant.components.light import ( @@ -30,15 +39,6 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, WorkMode from .entity import TuyaEntity -from .models import ( - DeviceWrapper, - DPCodeBooleanWrapper, - DPCodeEnumWrapper, - DPCodeIntegerWrapper, - DPCodeJsonWrapper, -) -from .type_information import IntegerTypeInformation -from .util import RemapHelper class _BrightnessWrapper(DPCodeIntegerWrapper): @@ -174,7 +174,7 @@ class _ColorDataWrapper(DPCodeJsonWrapper): s_type = DEFAULT_S_TYPE v_type = DEFAULT_V_TYPE - def read_device_status( + def read_device_status( # type: ignore[override] self, device: CustomerDevice ) -> tuple[float, float, float] | None: """Return a tuple (H, S, V) from this color data.""" diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index 7d630ef257c72..877c2aec60340 100644 --- a/homeassistant/components/tuya/manifest.json +++ b/homeassistant/components/tuya/manifest.json @@ -43,5 +43,8 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["tuya_sharing"], - "requirements": ["tuya-device-sharing-sdk==0.2.8"] + "requirements": [ + "tuya-device-handlers==0.0.10", + "tuya-device-sharing-sdk==0.2.8" + ] } diff --git a/homeassistant/components/tuya/models.py b/homeassistant/components/tuya/models.py deleted file mode 100644 index 07cb251e9e1fa..0000000000000 --- a/homeassistant/components/tuya/models.py +++ /dev/null @@ -1,329 +0,0 @@ -"""Tuya Home Assistant Base Device Model.""" - -from __future__ import annotations - -import logging -from typing import Any, Self - -from tuya_sharing import CustomerDevice - -from homeassistant.components.sensor import SensorStateClass - -from .type_information import ( - BitmapTypeInformation, - BooleanTypeInformation, - EnumTypeInformation, - IntegerTypeInformation, - JsonTypeInformation, - RawTypeInformation, - StringTypeInformation, - TypeInformation, -) - -_LOGGER = logging.getLogger(__name__) - - -class DeviceWrapper[T]: - """Base device wrapper.""" - - native_unit: str | None = None - suggested_unit: str | None = None - state_class: SensorStateClass | None = None - - max_value: float - min_value: float - value_step: float - - options: list[str] - - def initialize(self, device: CustomerDevice) -> None: - """Initialize the wrapper with device data. - - Called when the entity is added to Home Assistant. - Override in subclasses to perform initialization logic. - """ - - def skip_update( - self, - device: CustomerDevice, - updated_status_properties: list[str], - dp_timestamps: dict[str, int] | None, - ) -> bool: - """Determine if the wrapper should skip an update. - - The default is to always skip if updated properties is given, - unless overridden in subclasses. - """ - # If updated_status_properties is None, we should not skip, - # as we don't have information on what was updated - # This happens for example on online/offline updates, where - # we still want to update the entity state - return updated_status_properties is not None - - def read_device_status(self, device: CustomerDevice) -> T | None: - """Read device status and convert to a Home Assistant value.""" - raise NotImplementedError - - def get_update_commands( - self, device: CustomerDevice, value: T - ) -> list[dict[str, Any]]: - """Generate update commands for a Home Assistant action.""" - raise NotImplementedError - - -class DPCodeWrapper(DeviceWrapper): - """Base device wrapper for a single DPCode. - - Used as a common interface for referring to a DPCode, and - access read conversion routines. - """ - - def __init__(self, dpcode: str) -> None: - """Init DPCodeWrapper.""" - self.dpcode = dpcode - - def skip_update( - self, - device: CustomerDevice, - updated_status_properties: list[str], - dp_timestamps: dict[str, int] | None, - ) -> bool: - """Determine if the wrapper should skip an update. - - By default, skip if updated_status_properties is given and - does not include this dpcode. - """ - # If updated_status_properties is None, we should not skip, - # as we don't have information on what was updated - # This happens for example on online/offline updates, where - # we still want to update the entity state - return ( - updated_status_properties is not None - and self.dpcode not in updated_status_properties - ) - - def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any: - """Convert a Home Assistant value back to a raw device value. - - This is called by `get_update_commands` to prepare the value for sending - back to the device, and should be implemented in concrete classes if needed. - """ - raise NotImplementedError - - def get_update_commands( - self, device: CustomerDevice, value: Any - ) -> list[dict[str, Any]]: - """Get the update commands for the dpcode. - - The Home Assistant value is converted back to a raw device value. - """ - return [ - { - "code": self.dpcode, - "value": self._convert_value_to_raw_value(device, value), - } - ] - - -class DPCodeTypeInformationWrapper[T: TypeInformation](DPCodeWrapper): - """Base DPCode wrapper with Type Information.""" - - _DPTYPE: type[T] - type_information: T - - def __init__(self, dpcode: str, type_information: T) -> None: - """Init DPCodeWrapper.""" - super().__init__(dpcode) - self.type_information = type_information - - def read_device_status(self, device: CustomerDevice) -> Any | None: - """Read the device value for the dpcode.""" - return self.type_information.process_raw_value( - device.status.get(self.dpcode), device - ) - - @classmethod - def find_dpcode( - cls, - device: CustomerDevice, - dpcodes: str | tuple[str, ...] | None, - *, - prefer_function: bool = False, - ) -> Self | None: - """Find and return a DPCodeTypeInformationWrapper for the given DP codes.""" - if type_information := cls._DPTYPE.find_dpcode( - device, dpcodes, prefer_function=prefer_function - ): - return cls( - dpcode=type_information.dpcode, type_information=type_information - ) - return None - - -class DPCodeBooleanWrapper(DPCodeTypeInformationWrapper[BooleanTypeInformation]): - """Simple wrapper for boolean values. - - Supports True/False only. - """ - - _DPTYPE = BooleanTypeInformation - - def _convert_value_to_raw_value( - self, device: CustomerDevice, value: Any - ) -> Any | None: - """Convert a Home Assistant value back to a raw device value.""" - if value in (True, False): - return value - # Currently only called with boolean values - # Safety net in case of future changes - raise ValueError(f"Invalid boolean value `{value}`") - - -class DPCodeJsonWrapper(DPCodeTypeInformationWrapper[JsonTypeInformation]): - """Wrapper to extract information from a JSON value.""" - - _DPTYPE = JsonTypeInformation - - -class DPCodeEnumWrapper(DPCodeTypeInformationWrapper[EnumTypeInformation]): - """Simple wrapper for EnumTypeInformation values.""" - - _DPTYPE = EnumTypeInformation - - def __init__(self, dpcode: str, type_information: EnumTypeInformation) -> None: - """Init DPCodeEnumWrapper.""" - super().__init__(dpcode, type_information) - self.options = type_information.range - - def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any: - """Convert a Home Assistant value back to a raw device value.""" - if value in self.type_information.range: - return value - # Guarded by select option validation - # Safety net in case of future changes - raise ValueError( - f"Enum value `{value}` out of range: {self.type_information.range}" - ) - - -class DPCodeIntegerWrapper(DPCodeTypeInformationWrapper[IntegerTypeInformation]): - """Simple wrapper for IntegerTypeInformation values.""" - - _DPTYPE = IntegerTypeInformation - - def __init__(self, dpcode: str, type_information: IntegerTypeInformation) -> None: - """Init DPCodeIntegerWrapper.""" - super().__init__(dpcode, type_information) - self.native_unit = type_information.unit - self.min_value = self.type_information.scale_value(type_information.min) - self.max_value = self.type_information.scale_value(type_information.max) - self.value_step = self.type_information.scale_value(type_information.step) - - def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any: - """Convert a Home Assistant value back to a raw device value.""" - new_value = round(value * (10**self.type_information.scale)) - if self.type_information.min <= new_value <= self.type_information.max: - return new_value - # Guarded by number validation - # Safety net in case of future changes - raise ValueError( - f"Value `{new_value}` (converted from `{value}`) out of range:" - f" ({self.type_information.min}-{self.type_information.max})" - ) - - -class DPCodeDeltaIntegerWrapper(DPCodeIntegerWrapper): - """Wrapper for integer values with delta report accumulation. - - This wrapper handles sensors that report incremental (delta) values - instead of cumulative totals. It accumulates the delta values locally - to provide a running total. - """ - - _accumulated_value: float = 0 - _last_dp_timestamp: int | None = None - - def __init__(self, dpcode: str, type_information: IntegerTypeInformation) -> None: - """Init DPCodeDeltaIntegerWrapper.""" - super().__init__(dpcode, type_information) - # Delta reports use TOTAL_INCREASING state class - self.state_class = SensorStateClass.TOTAL_INCREASING - - def skip_update( - self, - device: CustomerDevice, - updated_status_properties: list[str], - dp_timestamps: dict[str, int] | None, - ) -> bool: - """Override skip_update to process delta updates. - - Processes delta accumulation before determining if update should be skipped. - """ - if ( - super().skip_update(device, updated_status_properties, dp_timestamps) - or dp_timestamps is None - or (current_timestamp := dp_timestamps.get(self.dpcode)) is None - or current_timestamp == self._last_dp_timestamp - or (raw_value := super().read_device_status(device)) is None - ): - return True - - delta = float(raw_value) - self._accumulated_value += delta - _LOGGER.debug( - "Delta update for %s: +%s, total: %s", - self.dpcode, - delta, - self._accumulated_value, - ) - - self._last_dp_timestamp = current_timestamp - return False - - def read_device_status(self, device: CustomerDevice) -> float | None: - """Read device status, returning accumulated value for delta reports.""" - return self._accumulated_value - - -class DPCodeRawWrapper(DPCodeTypeInformationWrapper[RawTypeInformation]): - """Wrapper to extract information from a RAW/binary value.""" - - _DPTYPE = RawTypeInformation - - -class DPCodeStringWrapper(DPCodeTypeInformationWrapper[StringTypeInformation]): - """Wrapper to extract information from a STRING value.""" - - _DPTYPE = StringTypeInformation - - -class DPCodeBitmapBitWrapper(DPCodeWrapper): - """Simple wrapper for a specific bit in bitmap values.""" - - def __init__(self, dpcode: str, mask: int) -> None: - """Init DPCodeBitmapWrapper.""" - super().__init__(dpcode) - self._mask = mask - - def read_device_status(self, device: CustomerDevice) -> bool | None: - """Read the device value for the dpcode.""" - if (raw_value := device.status.get(self.dpcode)) is None: - return None - return (raw_value & (1 << self._mask)) != 0 - - @classmethod - def find_dpcode( - cls, - device: CustomerDevice, - dpcodes: str | tuple[str, ...], - *, - bitmap_key: str, - ) -> Self | None: - """Find and return a DPCodeBitmapBitWrapper for the given DP codes.""" - if ( - type_information := BitmapTypeInformation.find_dpcode(device, dpcodes) - ) and bitmap_key in type_information.label: - return cls( - type_information.dpcode, type_information.label.index(bitmap_key) - ) - return None diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index faa76d1a39245..ea24e04a1040e 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -2,6 +2,8 @@ from __future__ import annotations +from tuya_device_handlers.device_wrapper.base import DeviceWrapper +from tuya_device_handlers.device_wrapper.common import DPCodeIntegerWrapper from tuya_sharing import CustomerDevice, Manager from homeassistant.components.number import ( @@ -25,7 +27,6 @@ DPCode, ) from .entity import TuyaEntity -from .models import DeviceWrapper, DPCodeIntegerWrapper NUMBERS: dict[DeviceCategory, tuple[NumberEntityDescription, ...]] = { DeviceCategory.BH: ( diff --git a/homeassistant/components/tuya/raw_data_models.py b/homeassistant/components/tuya/raw_data_models.py deleted file mode 100644 index c0ba9947fef07..0000000000000 --- a/homeassistant/components/tuya/raw_data_models.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Parsers for RAW (base64-encoded bytes) values.""" - -from dataclasses import dataclass -import struct -from typing import Self - - -@dataclass(kw_only=True) -class ElectricityData: - """Electricity RAW value.""" - - current: float - power: float - voltage: float - - @classmethod - def from_bytes(cls, raw: bytes) -> Self | None: - """Parse bytes and return an ElectricityValue object.""" - # Format: - # - legacy: 8 bytes - # - v01: [ver=0x01][len=0x0F][data(15 bytes)] - # - v02: [ver=0x02][len=0x0F][data(15 bytes)][sign_bitmap(1 byte)] - # Data layout (big-endian): - # - voltage: 2B, unit 0.1 V - # - current: 3B, unit 0.001 A (i.e., mA) - # - active power: 3B, unit 0.001 kW (i.e., W) - # - reactive power: 3B, unit 0.001 kVar - # - apparent power: 3B, unit 0.001 kVA - # - power factor: 1B, unit 0.01 - # Sign bitmap (v02 only, 1 bit means negative): - # - bit0 current - # - bit1 active power - # - bit2 reactive - # - bit3 power factor - - is_v1 = len(raw) == 17 and raw[0:2] == b"\x01\x0f" - is_v2 = len(raw) == 18 and raw[0:2] == b"\x02\x0f" - if is_v1 or is_v2: - data = raw[2:17] - - voltage = struct.unpack(">H", data[0:2])[0] / 10.0 - current = struct.unpack(">L", b"\x00" + data[2:5])[0] - power = struct.unpack(">L", b"\x00" + data[5:8])[0] - - if is_v2: - sign_bitmap = raw[17] - if sign_bitmap & 0x01: - current = -current - if sign_bitmap & 0x02: - power = -power - - return cls(current=current, power=power, voltage=voltage) - - if len(raw) >= 8: - voltage = struct.unpack(">H", raw[0:2])[0] / 10.0 - current = struct.unpack(">L", b"\x00" + raw[2:5])[0] - power = struct.unpack(">L", b"\x00" + raw[5:8])[0] - return cls(current=current, power=power, voltage=voltage) - - return None diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index f5078b4012045..67eaf94e10cff 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -2,6 +2,8 @@ from __future__ import annotations +from tuya_device_handlers.device_wrapper.base import DeviceWrapper +from tuya_device_handlers.device_wrapper.common import DPCodeEnumWrapper from tuya_sharing import CustomerDevice, Manager from homeassistant.components.select import SelectEntity, SelectEntityDescription @@ -13,7 +15,6 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -from .models import DeviceWrapper, DPCodeEnumWrapper # All descriptions can be found here. Mostly the Enum data types in the # default instructions set of each category end up being a select. diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 90789c33aef06..a3b756c5150c3 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -4,6 +4,24 @@ from dataclasses import dataclass +from tuya_device_handlers.device_wrapper.base import DeviceWrapper +from tuya_device_handlers.device_wrapper.common import ( + DPCodeEnumWrapper, + DPCodeIntegerWrapper, + DPCodeTypeInformationWrapper, + DPCodeWrapper, +) +from tuya_device_handlers.device_wrapper.sensor import ( + DeltaIntegerWrapper, + ElectricityCurrentJsonWrapper, + ElectricityCurrentRawWrapper, + ElectricityPowerJsonWrapper, + ElectricityPowerRawWrapper, + ElectricityVoltageJsonWrapper, + ElectricityVoltageRawWrapper, + WindDirectionEnumWrapper, +) +from tuya_device_handlers.type_information import IntegerTypeInformation from tuya_sharing import CustomerDevice, Manager from homeassistant.components.sensor import ( @@ -38,138 +56,10 @@ DPCode, ) from .entity import TuyaEntity -from .models import ( - DeviceWrapper, - DPCodeDeltaIntegerWrapper, - DPCodeEnumWrapper, - DPCodeIntegerWrapper, - DPCodeJsonWrapper, - DPCodeRawWrapper, - DPCodeTypeInformationWrapper, - DPCodeWrapper, -) -from .raw_data_models import ElectricityData -from .type_information import EnumTypeInformation, IntegerTypeInformation - - -class _WindDirectionWrapper(DPCodeTypeInformationWrapper[EnumTypeInformation]): - """Custom DPCode Wrapper for converting enum to wind direction.""" - - _DPTYPE = EnumTypeInformation - - _WIND_DIRECTIONS = { - "north": 0.0, - "north_north_east": 22.5, - "north_east": 45.0, - "east_north_east": 67.5, - "east": 90.0, - "east_south_east": 112.5, - "south_east": 135.0, - "south_south_east": 157.5, - "south": 180.0, - "south_south_west": 202.5, - "south_west": 225.0, - "west_south_west": 247.5, - "west": 270.0, - "west_north_west": 292.5, - "north_west": 315.0, - "north_north_west": 337.5, - } - - def read_device_status(self, device: CustomerDevice) -> float | None: - """Read the device value for the dpcode.""" - if (raw_value := device.status.get(self.dpcode)) in self.type_information.range: - return self._WIND_DIRECTIONS.get(raw_value) - return None - - -class _JsonElectricityCurrentWrapper(DPCodeJsonWrapper): - """Custom DPCode Wrapper for extracting electricity current from JSON.""" - - native_unit = UnitOfElectricCurrent.AMPERE - - def read_device_status(self, device: CustomerDevice) -> float | None: - """Read the device value for the dpcode.""" - if (status := super().read_device_status(device)) is None: - return None - return status.get("electricCurrent") - - -class _JsonElectricityPowerWrapper(DPCodeJsonWrapper): - """Custom DPCode Wrapper for extracting electricity power from JSON.""" - - native_unit = UnitOfPower.KILO_WATT - def read_device_status(self, device: CustomerDevice) -> float | None: - """Read the device value for the dpcode.""" - if (status := super().read_device_status(device)) is None: - return None - return status.get("power") - - -class _JsonElectricityVoltageWrapper(DPCodeJsonWrapper): - """Custom DPCode Wrapper for extracting electricity voltage from JSON.""" - - native_unit = UnitOfElectricPotential.VOLT - - def read_device_status(self, device: CustomerDevice) -> float | None: - """Read the device value for the dpcode.""" - if (status := super().read_device_status(device)) is None: - return None - return status.get("voltage") - - -class _RawElectricityDataWrapper(DPCodeRawWrapper): - """Custom DPCode Wrapper for extracting ElectricityData from base64.""" - - def _convert(self, value: ElectricityData) -> float: - """Extract specific value from T.""" - raise NotImplementedError - - def read_device_status(self, device: CustomerDevice) -> float | None: - """Read the device value for the dpcode.""" - if (raw_value := super().read_device_status(device)) is None or ( - value := ElectricityData.from_bytes(raw_value) - ) is None: - return None - return self._convert(value) - - -class _RawElectricityCurrentWrapper(_RawElectricityDataWrapper): - """Custom DPCode Wrapper for extracting electricity current from base64.""" - - native_unit = UnitOfElectricCurrent.MILLIAMPERE - suggested_unit = UnitOfElectricCurrent.AMPERE - - def _convert(self, value: ElectricityData) -> float: - """Extract specific value from ElectricityData.""" - return value.current - - -class _RawElectricityPowerWrapper(_RawElectricityDataWrapper): - """Custom DPCode Wrapper for extracting electricity power from base64.""" - - native_unit = UnitOfPower.WATT - suggested_unit = UnitOfPower.KILO_WATT - - def _convert(self, value: ElectricityData) -> float: - """Extract specific value from ElectricityData.""" - return value.power - - -class _RawElectricityVoltageWrapper(_RawElectricityDataWrapper): - """Custom DPCode Wrapper for extracting electricity voltage from base64.""" - - native_unit = UnitOfElectricPotential.VOLT - - def _convert(self, value: ElectricityData) -> float: - """Extract specific value from ElectricityData.""" - return value.voltage - - -CURRENT_WRAPPER = (_RawElectricityCurrentWrapper, _JsonElectricityCurrentWrapper) -POWER_WRAPPER = (_RawElectricityPowerWrapper, _JsonElectricityPowerWrapper) -VOLTAGE_WRAPPER = (_RawElectricityVoltageWrapper, _JsonElectricityVoltageWrapper) +CURRENT_WRAPPER = (ElectricityCurrentRawWrapper, ElectricityCurrentJsonWrapper) +POWER_WRAPPER = (ElectricityPowerRawWrapper, ElectricityPowerJsonWrapper) +VOLTAGE_WRAPPER = (ElectricityVoltageRawWrapper, ElectricityVoltageJsonWrapper) @dataclass(frozen=True) @@ -1070,7 +960,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): translation_key="wind_direction", device_class=SensorDeviceClass.WIND_DIRECTION, state_class=SensorStateClass.MEASUREMENT, - wrapper_class=(_WindDirectionWrapper,), + wrapper_class=(WindDirectionEnumWrapper,), ), TuyaSensorEntityDescription( key=DPCode.DEW_POINT_TEMP, @@ -1744,7 +1634,7 @@ def _get_dpcode_wrapper( # Check for integer type first, using delta wrapper only for sum report_type if type_information := IntegerTypeInformation.find_dpcode(device, dpcode): if type_information.report_type == "sum": - return DPCodeDeltaIntegerWrapper(type_information.dpcode, type_information) + return DeltaIntegerWrapper(type_information.dpcode, type_information) return DPCodeIntegerWrapper(type_information.dpcode, type_information) return DPCodeEnumWrapper.find_dpcode(device, dpcode) @@ -1802,8 +1692,13 @@ def __init__( self._attr_native_unit_of_measurement = dpcode_wrapper.native_unit if description.suggested_unit_of_measurement is None: self._attr_suggested_unit_of_measurement = dpcode_wrapper.suggested_unit - if description.state_class is None: - self._attr_state_class = dpcode_wrapper.state_class + if ( + description.state_class is None + # For integer type DPs with "sum" report type, we can assume it's a total + # increasing sensor + and isinstance(dpcode_wrapper, DeltaIntegerWrapper) + ): + self._attr_state_class = SensorStateClass.TOTAL_INCREASING self._validate_device_class_unit() diff --git a/homeassistant/components/tuya/siren.py b/homeassistant/components/tuya/siren.py index 7031923673359..5836f27b2edf9 100644 --- a/homeassistant/components/tuya/siren.py +++ b/homeassistant/components/tuya/siren.py @@ -4,6 +4,8 @@ from typing import Any +from tuya_device_handlers.device_wrapper.base import DeviceWrapper +from tuya_device_handlers.device_wrapper.common import DPCodeBooleanWrapper from tuya_sharing import CustomerDevice, Manager from homeassistant.components.siren import ( @@ -19,7 +21,6 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -from .models import DeviceWrapper, DPCodeBooleanWrapper SIRENS: dict[DeviceCategory, tuple[SirenEntityDescription, ...]] = { DeviceCategory.CO2BJ: ( diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index 353ff432bef54..f72d84b479aa6 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -5,6 +5,8 @@ from dataclasses import dataclass from typing import Any +from tuya_device_handlers.device_wrapper.base import DeviceWrapper +from tuya_device_handlers.device_wrapper.common import DPCodeBooleanWrapper from tuya_sharing import CustomerDevice, Manager from homeassistant.components.switch import ( @@ -27,7 +29,6 @@ from . import TuyaConfigEntry from .const import DOMAIN, TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -from .models import DeviceWrapper, DPCodeBooleanWrapper @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/tuya/type_information.py b/homeassistant/components/tuya/type_information.py deleted file mode 100644 index a3a2122c05585..0000000000000 --- a/homeassistant/components/tuya/type_information.py +++ /dev/null @@ -1,302 +0,0 @@ -"""Type information classes for the Tuya integration.""" - -from __future__ import annotations - -import base64 -from dataclasses import dataclass -from typing import Any, ClassVar, Self, cast - -from tuya_sharing import CustomerDevice - -from homeassistant.util.json import json_loads_object - -from .const import LOGGER, DPType -from .util import parse_dptype - -# Dictionary to track logged warnings to avoid spamming logs -# Keyed by device ID -DEVICE_WARNINGS: dict[str, set[str]] = {} - - -def _should_log_warning(device_id: str, warning_key: str) -> bool: - """Check if a warning has already been logged for a device and add it if not. - - Returns: True if the warning should be logged, False if it was already logged. - """ - if (device_warnings := DEVICE_WARNINGS.get(device_id)) is None: - device_warnings = set() - DEVICE_WARNINGS[device_id] = device_warnings - if warning_key in device_warnings: - return False - DEVICE_WARNINGS[device_id].add(warning_key) - return True - - -@dataclass(kw_only=True) -class TypeInformation[T]: - """Type information. - - As provided by the SDK, from `device.function` / `device.status_range`. - """ - - _DPTYPE: ClassVar[DPType] - dpcode: str - type_data: str - - def process_raw_value( - self, raw_value: Any | None, device: CustomerDevice - ) -> T | None: - """Read and process raw value against this type information. - - Base implementation does no validation, subclasses may override to provide - specific validation. - """ - return raw_value - - @classmethod - def _from_json( - cls, dpcode: str, type_data: str, *, report_type: str | None - ) -> Self | None: - """Load JSON string and return a TypeInformation object.""" - return cls(dpcode=dpcode, type_data=type_data) - - @classmethod - def find_dpcode( - cls, - device: CustomerDevice, - dpcodes: str | tuple[str, ...] | None, - *, - prefer_function: bool = False, - ) -> Self | None: - """Find type information for a matching DP code available for this device.""" - if dpcodes is None: - return None - - if not isinstance(dpcodes, tuple): - dpcodes = (dpcodes,) - - lookup_tuple = ( - (device.function, device.status_range) - if prefer_function - else (device.status_range, device.function) - ) - - for dpcode in dpcodes: - report_type = ( - sr.report_type if (sr := device.status_range.get(dpcode)) else None - ) - for device_specs in lookup_tuple: - if ( - (current_definition := device_specs.get(dpcode)) - and parse_dptype(current_definition.type) is cls._DPTYPE - and ( - type_information := cls._from_json( - dpcode=dpcode, - type_data=current_definition.values, - report_type=report_type, - ) - ) - ): - return type_information - - return None - - -@dataclass(kw_only=True) -class BitmapTypeInformation(TypeInformation[int]): - """Bitmap type information.""" - - _DPTYPE = DPType.BITMAP - - label: list[str] - - @classmethod - def _from_json( - cls, dpcode: str, type_data: str, *, report_type: str | None - ) -> Self | None: - """Load JSON string and return a BitmapTypeInformation object.""" - if not (parsed := cast(dict[str, Any] | None, json_loads_object(type_data))): - return None - return cls( - dpcode=dpcode, - type_data=type_data, - label=parsed["label"], - ) - - -@dataclass(kw_only=True) -class BooleanTypeInformation(TypeInformation[bool]): - """Boolean type information.""" - - _DPTYPE = DPType.BOOLEAN - - def process_raw_value( - self, raw_value: Any | None, device: CustomerDevice - ) -> bool | None: - """Read and process raw value against this type information.""" - if raw_value is None: - return None - # Validate input against defined range - if raw_value not in (True, False): - if _should_log_warning( - device.id, f"boolean_out_range|{self.dpcode}|{raw_value}" - ): - LOGGER.warning( - "Found invalid boolean value `%s` for datapoint `%s` in product " - "id `%s`, expected one of `%s`; please report this defect to " - "Tuya support", - raw_value, - self.dpcode, - device.product_id, - (True, False), - ) - return None - return raw_value - - -@dataclass(kw_only=True) -class EnumTypeInformation(TypeInformation[str]): - """Enum type information.""" - - _DPTYPE = DPType.ENUM - - range: list[str] - - def process_raw_value( - self, raw_value: Any | None, device: CustomerDevice - ) -> str | None: - """Read and process raw value against this type information.""" - if raw_value is None: - return None - # Validate input against defined range - if raw_value not in self.range: - if _should_log_warning( - device.id, f"enum_out_range|{self.dpcode}|{raw_value}" - ): - LOGGER.warning( - "Found invalid enum value `%s` for datapoint `%s` in product " - "id `%s`, expected one of `%s`; please report this defect to " - "Tuya support", - raw_value, - self.dpcode, - device.product_id, - self.range, - ) - return None - return raw_value - - @classmethod - def _from_json( - cls, dpcode: str, type_data: str, *, report_type: str | None - ) -> Self | None: - """Load JSON string and return an EnumTypeInformation object.""" - if not (parsed := json_loads_object(type_data)): - return None - return cls( - dpcode=dpcode, - type_data=type_data, - **cast(dict[str, list[str]], parsed), - ) - - -@dataclass(kw_only=True) -class IntegerTypeInformation(TypeInformation[float]): - """Integer type information.""" - - _DPTYPE = DPType.INTEGER - - min: int - max: int - scale: int - step: int - unit: str | None = None - report_type: str | None - - def scale_value(self, value: int) -> float: - """Scale a value.""" - return value / (10**self.scale) - - def scale_value_back(self, value: float) -> int: - """Return raw value for scaled.""" - return round(value * (10**self.scale)) - - def process_raw_value( - self, raw_value: Any | None, device: CustomerDevice - ) -> float | None: - """Read and process raw value against this type information.""" - if raw_value is None: - return None - # Validate input against defined range - if not isinstance(raw_value, int) or not (self.min <= raw_value <= self.max): - if _should_log_warning( - device.id, f"integer_out_range|{self.dpcode}|{raw_value}" - ): - LOGGER.warning( - "Found invalid integer value `%s` for datapoint `%s` in product " - "id `%s`, expected integer value between %s and %s; please report " - "this defect to Tuya support", - raw_value, - self.dpcode, - device.product_id, - self.min, - self.max, - ) - - return None - return raw_value / (10**self.scale) - - @classmethod - def _from_json( - cls, dpcode: str, type_data: str, *, report_type: str | None - ) -> Self | None: - """Load JSON string and return an IntegerTypeInformation object.""" - if not (parsed := cast(dict[str, Any] | None, json_loads_object(type_data))): - return None - - return cls( - dpcode=dpcode, - type_data=type_data, - min=int(parsed["min"]), - max=int(parsed["max"]), - scale=int(parsed["scale"]), - step=int(parsed["step"]), - unit=parsed.get("unit"), - report_type=report_type, - ) - - -@dataclass(kw_only=True) -class JsonTypeInformation(TypeInformation[dict[str, Any]]): - """Json type information.""" - - _DPTYPE = DPType.JSON - - def process_raw_value( - self, raw_value: Any | None, device: CustomerDevice - ) -> dict[str, Any] | None: - """Read and process raw value against this type information.""" - if raw_value is None: - return None - return json_loads_object(raw_value) - - -@dataclass(kw_only=True) -class RawTypeInformation(TypeInformation[bytes]): - """Raw type information.""" - - _DPTYPE = DPType.RAW - - def process_raw_value( - self, raw_value: Any | None, device: CustomerDevice - ) -> bytes | None: - """Read and process raw value against this type information.""" - if raw_value is None: - return None - return base64.b64decode(raw_value) - - -@dataclass(kw_only=True) -class StringTypeInformation(TypeInformation[str]): - """String type information.""" - - _DPTYPE = DPType.STRING diff --git a/homeassistant/components/tuya/util.py b/homeassistant/components/tuya/util.py index 0b1b549d62a13..bf00f0c9d069f 100644 --- a/homeassistant/components/tuya/util.py +++ b/homeassistant/components/tuya/util.py @@ -2,27 +2,11 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any - from tuya_sharing import CustomerDevice from homeassistant.exceptions import ServiceValidationError -from .const import DOMAIN, DPCode, DPType - -if TYPE_CHECKING: - from .type_information import IntegerTypeInformation - -_DPTYPE_MAPPING: dict[str, DPType] = { - "bitmap": DPType.BITMAP, - "bool": DPType.BOOLEAN, - "enum": DPType.ENUM, - "json": DPType.JSON, - "raw": DPType.RAW, - "string": DPType.STRING, - "value": DPType.INTEGER, -} +from .const import DOMAIN, DPCode def get_dpcode( @@ -46,90 +30,6 @@ def get_dpcode( return None -def parse_dptype(dptype: str) -> DPType | None: - """Parse DPType from device DPCode information.""" - try: - return DPType(dptype) - except ValueError: - # Sometimes, we get ill-formed DPTypes from the cloud, - # this fixes them and maps them to the correct DPType. - return _DPTYPE_MAPPING.get(dptype) - - -@dataclass(kw_only=True) -class RemapHelper: - """Helper class for remapping values.""" - - source_min: int - source_max: int - target_min: int - target_max: int - - @classmethod - def from_type_information( - cls, - type_information: IntegerTypeInformation, - target_min: int, - target_max: int, - ) -> RemapHelper: - """Create RemapHelper from IntegerTypeInformation.""" - return cls( - source_min=type_information.min, - source_max=type_information.max, - target_min=target_min, - target_max=target_max, - ) - - @classmethod - def from_function_data( - cls, function_data: dict[str, Any], target_min: int, target_max: int - ) -> RemapHelper: - """Create RemapHelper from function_data.""" - return cls( - source_min=function_data["min"], - source_max=function_data["max"], - target_min=target_min, - target_max=target_max, - ) - - def remap_value_to(self, value: float, *, reverse: bool = False) -> float: - """Remap a value from this range to a new range.""" - return self.remap_value( - value, - self.source_min, - self.source_max, - self.target_min, - self.target_max, - reverse=reverse, - ) - - def remap_value_from(self, value: float, *, reverse: bool = False) -> float: - """Remap a value from its current range to this range.""" - return self.remap_value( - value, - self.target_min, - self.target_max, - self.source_min, - self.source_max, - reverse=reverse, - ) - - @staticmethod - def remap_value( - value: float, - from_min: float, - from_max: float, - to_min: float, - to_max: float, - *, - reverse: bool = False, - ) -> float: - """Remap a value from its current range, to a new range.""" - if reverse: - value = from_max - value + from_min - return ((value - from_min) / (from_max - from_min)) * (to_max - to_min) + to_min - - class ActionDPCodeNotFoundError(ServiceValidationError): """Custom exception for action DP code not found errors.""" diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index a9ce6b7044f6c..0c743887b8777 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -4,6 +4,11 @@ from typing import Any, Self +from tuya_device_handlers.device_wrapper.base import DeviceWrapper +from tuya_device_handlers.device_wrapper.common import ( + DPCodeBooleanWrapper, + DPCodeEnumWrapper, +) from tuya_sharing import CustomerDevice, Manager from homeassistant.components.vacuum import ( @@ -18,7 +23,6 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -from .models import DeviceWrapper, DPCodeBooleanWrapper, DPCodeEnumWrapper class _VacuumActivityWrapper(DeviceWrapper): diff --git a/homeassistant/components/tuya/valve.py b/homeassistant/components/tuya/valve.py index e617f59264e8e..fc9ccbd970014 100644 --- a/homeassistant/components/tuya/valve.py +++ b/homeassistant/components/tuya/valve.py @@ -2,6 +2,8 @@ from __future__ import annotations +from tuya_device_handlers.device_wrapper.base import DeviceWrapper +from tuya_device_handlers.device_wrapper.common import DPCodeBooleanWrapper from tuya_sharing import CustomerDevice, Manager from homeassistant.components.valve import ( @@ -17,7 +19,6 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -from .models import DeviceWrapper, DPCodeBooleanWrapper VALVES: dict[DeviceCategory, tuple[ValveEntityDescription, ...]] = { DeviceCategory.SFKZQ: ( diff --git a/homeassistant/components/uptime_kuma/const.py b/homeassistant/components/uptime_kuma/const.py index 2bd4b1f91659c..990f8899e6da7 100644 --- a/homeassistant/components/uptime_kuma/const.py +++ b/homeassistant/components/uptime_kuma/const.py @@ -24,3 +24,5 @@ MonitorType.TAILSCALE_PING, MonitorType.DNS, } + +LOCAL_INSTANCE = ("127.0.0.1", "localhost", "a0d7b954-uptime-kuma") diff --git a/homeassistant/components/uptime_kuma/sensor.py b/homeassistant/components/uptime_kuma/sensor.py index da6acf0dd2d64..9f5c774dcd02d 100644 --- a/homeassistant/components/uptime_kuma/sensor.py +++ b/homeassistant/components/uptime_kuma/sensor.py @@ -9,6 +9,7 @@ from pythonkuma import MonitorType, UptimeKumaMonitor from pythonkuma.models import MonitorStatus +from yarl import URL from homeassistant.components.sensor import ( SensorDeviceClass, @@ -23,7 +24,7 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, HAS_CERT, HAS_HOST, HAS_PORT, HAS_URL +from .const import DOMAIN, HAS_CERT, HAS_HOST, HAS_PORT, HAS_URL, LOCAL_INSTANCE from .coordinator import UptimeKumaConfigEntry, UptimeKumaDataUpdateCoordinator PARALLEL_UPDATES = 0 @@ -253,16 +254,21 @@ def __init__( self._attr_unique_id = ( f"{coordinator.config_entry.entry_id}_{monitor!s}_{entity_description.key}" ) + + url = URL(coordinator.config_entry.data[CONF_URL]) / "dashboard" + if url.host in LOCAL_INSTANCE: + configuration_url = None + elif isinstance(monitor, int): + configuration_url = url / str(monitor) + else: + configuration_url = url + self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, name=coordinator.data[monitor].monitor_name, identifiers={(DOMAIN, f"{coordinator.config_entry.entry_id}_{monitor!s}")}, manufacturer="Uptime Kuma", - configuration_url=( - None - if "127.0.0.1" in (url := coordinator.config_entry.data[CONF_URL]) - else url - ), + configuration_url=configuration_url, sw_version=coordinator.api.version.version, ) diff --git a/homeassistant/components/uptime_kuma/update.py b/homeassistant/components/uptime_kuma/update.py index 6fe4e477f0bf0..0e9f384641519 100644 --- a/homeassistant/components/uptime_kuma/update.py +++ b/homeassistant/components/uptime_kuma/update.py @@ -4,19 +4,21 @@ from enum import StrEnum +from yarl import URL + from homeassistant.components.update import ( UpdateEntity, UpdateEntityDescription, UpdateEntityFeature, ) -from homeassistant.const import CONF_URL +from homeassistant.const import CONF_URL, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import UPTIME_KUMA_KEY -from .const import DOMAIN +from .const import DOMAIN, LOCAL_INSTANCE from .coordinator import ( UptimeKumaConfigEntry, UptimeKumaDataUpdateCoordinator, @@ -53,6 +55,7 @@ class UptimeKumaUpdateEntity( entity_description = UpdateEntityDescription( key=UptimeKumaUpdate.UPDATE, translation_key=UptimeKumaUpdate.UPDATE, + entity_category=EntityCategory.DIAGNOSTIC, ) _attr_supported_features = UpdateEntityFeature.RELEASE_NOTES _attr_has_entity_name = True @@ -66,12 +69,14 @@ def __init__( super().__init__(coordinator) self.update_checker = update_coordinator + url = URL(coordinator.config_entry.data[CONF_URL]) / "dashboard" + configuration_url = None if url.host in LOCAL_INSTANCE else url self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, name=coordinator.config_entry.title, identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, manufacturer="Uptime Kuma", - configuration_url=coordinator.config_entry.data[CONF_URL], + configuration_url=configuration_url, sw_version=coordinator.api.version.version, ) self._attr_unique_id = ( diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 288f40727d042..47e18e9e9ddd7 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -63,6 +63,7 @@ DEFAULT_NAME = "Vacuum cleaner robot" ISSUE_SEGMENTS_CHANGED = "segments_changed" +ISSUE_SEGMENTS_MAPPING_NOT_CONFIGURED = "segments_mapping_not_configured" _BATTERY_DEPRECATION_IGNORED_PLATFORMS = ("template",) @@ -189,6 +190,9 @@ class StateVacuumEntity( _attr_activity: VacuumActivity | None = None _attr_supported_features: VacuumEntityFeature = VacuumEntityFeature(0) + _segments_not_configured_issue_created: bool = False + _segments_changed_last_seen: list[dict[str, Any]] | None = None + __vacuum_legacy_battery_level: bool = False __vacuum_legacy_battery_icon: bool = False __vacuum_legacy_battery_feature: bool = False @@ -232,6 +236,17 @@ def add_to_platform_start( if self.__vacuum_legacy_battery_icon: self._report_deprecated_battery_properties("battery_icon") + @callback + def async_write_ha_state(self) -> None: + """Write the state to the state machine.""" + super().async_write_ha_state() + self._async_check_segments_issues() + + @callback + def async_registry_entry_updated(self) -> None: + """Run when the entity registry entry has been updated.""" + self._async_check_segments_issues() + @callback def _report_deprecated_battery_properties(self, property: str) -> None: """Report on deprecated use of battery properties. @@ -489,6 +504,61 @@ def async_create_segments_issue(self) -> None: "entity_id": self.entity_id, }, ) + options: Mapping[str, Any] = self.registry_entry.options.get(DOMAIN, {}) + self._segments_changed_last_seen = options.get("last_seen_segments") + + @callback + def _async_check_segments_issues(self) -> None: + """Create or delete segment-related repair issues.""" + if self.registry_entry is None: + return + + options: Mapping[str, Any] = self.registry_entry.options.get(DOMAIN, {}) + should_have_not_configured_issue = ( + VacuumEntityFeature.CLEAN_AREA in self.supported_features + and options.get("area_mapping") is None + ) + + if ( + should_have_not_configured_issue + and not self._segments_not_configured_issue_created + ): + issue_id = ( + f"{ISSUE_SEGMENTS_MAPPING_NOT_CONFIGURED}_{self.registry_entry.id}" + ) + ir.async_create_issue( + self.hass, + DOMAIN, + issue_id, + data={ + "entry_id": self.registry_entry.id, + "entity_id": self.entity_id, + }, + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key=ISSUE_SEGMENTS_MAPPING_NOT_CONFIGURED, + translation_placeholders={ + "entity_id": self.entity_id, + }, + ) + self._segments_not_configured_issue_created = True + elif ( + not should_have_not_configured_issue + and self._segments_not_configured_issue_created + ): + issue_id = ( + f"{ISSUE_SEGMENTS_MAPPING_NOT_CONFIGURED}_{self.registry_entry.id}" + ) + ir.async_delete_issue(self.hass, DOMAIN, issue_id) + self._segments_not_configured_issue_created = False + + if self._segments_changed_last_seen is not None and ( + VacuumEntityFeature.CLEAN_AREA not in self.supported_features + or options.get("last_seen_segments") != self._segments_changed_last_seen + ): + issue_id = f"{ISSUE_SEGMENTS_CHANGED}_{self.registry_entry.id}" + ir.async_delete_issue(self.hass, DOMAIN, issue_id) + self._segments_changed_last_seen = None def locate(self, **kwargs: Any) -> None: """Locate the vacuum cleaner.""" diff --git a/homeassistant/components/vacuum/strings.json b/homeassistant/components/vacuum/strings.json index 1695e1f2a4ca6..778261713b0bb 100644 --- a/homeassistant/components/vacuum/strings.json +++ b/homeassistant/components/vacuum/strings.json @@ -93,6 +93,10 @@ "segments_changed": { "description": "", "title": "Vacuum segments have changed for {entity_id}" + }, + "segments_mapping_not_configured": { + "description": "", + "title": "Vacuum segment mapping not configured for {entity_id}" } }, "selector": { diff --git a/homeassistant/components/volvo/__init__.py b/homeassistant/components/volvo/__init__.py index a4f1365274f23..a606ffae0e58f 100644 --- a/homeassistant/components/volvo/__init__.py +++ b/homeassistant/components/volvo/__init__.py @@ -14,12 +14,14 @@ ConfigEntryError, ConfigEntryNotReady, ) +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( ImplementationUnavailableError, OAuth2Session, async_get_config_entry_implementation, ) +from homeassistant.helpers.typing import ConfigType from .api import VolvoAuth from .const import CONF_VIN, DOMAIN, PLATFORMS @@ -32,6 +34,16 @@ VolvoSlowIntervalCoordinator, VolvoVerySlowIntervalCoordinator, ) +from .services import async_setup_services + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Volvo integration.""" + + await async_setup_services(hass) + return True async def async_setup_entry(hass: HomeAssistant, entry: VolvoConfigEntry) -> bool: diff --git a/homeassistant/components/volvo/icons.json b/homeassistant/components/volvo/icons.json index 9e41dab45ca17..5f888dc890e09 100644 --- a/homeassistant/components/volvo/icons.json +++ b/homeassistant/components/volvo/icons.json @@ -384,5 +384,10 @@ "default": "mdi:map-marker-distance" } } + }, + "services": { + "get_image_url": { + "service": "mdi:image-multiple-outline" + } } } diff --git a/homeassistant/components/volvo/quality_scale.yaml b/homeassistant/components/volvo/quality_scale.yaml index cdf28b1f958a9..089a0a9b92f37 100644 --- a/homeassistant/components/volvo/quality_scale.yaml +++ b/homeassistant/components/volvo/quality_scale.yaml @@ -1,19 +1,13 @@ rules: # Bronze - action-setup: - status: exempt - comment: | - The integration does not provide any additional actions. + action-setup: done appropriate-polling: done brands: done common-modules: done config-flow-test-coverage: done config-flow: done dependency-transparency: done - docs-actions: - status: exempt - comment: | - The integration does not provide any additional actions. + docs-actions: done docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done @@ -26,10 +20,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: exempt - comment: | - The integration does not provide any additional actions. + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done diff --git a/homeassistant/components/volvo/services.py b/homeassistant/components/volvo/services.py new file mode 100644 index 0000000000000..4f8ff3739ec3d --- /dev/null +++ b/homeassistant/components/volvo/services.py @@ -0,0 +1,216 @@ +"""Volvo services.""" + +import asyncio +import logging +from typing import Any +from urllib import parse + +from httpx import AsyncClient, HTTPError, HTTPStatusError +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.httpx_client import get_async_client + +from .const import DOMAIN +from .coordinator import VolvoConfigEntry + +_LOGGER = logging.getLogger(__name__) + +CONF_CONFIG_ENTRY_ID = "entry" +CONF_IMAGE_TYPES = "images" +SERVICE_GET_IMAGE_URL = "get_image_url" +SERVICE_GET_IMAGE_URL_SCHEMA = vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY_ID): str, + vol.Optional(CONF_IMAGE_TYPES): vol.All(cv.ensure_list, [str]), + } +) + +_HEADERS = { + "Accept-Language": "en-GB", + "Sec-Fetch-User": "?1", +} + +_PARAM_IMAGE_ANGLE_MAP = { + "exterior_back": "6", + "exterior_back_left": "5", + "exterior_back_right": "2", + "exterior_front": "3", + "exterior_front_left": "4", + "exterior_front_right": "0", + "exterior_side_left": "7", + "exterior_side_right": "1", +} +_IMAGE_ANGLE_MAP = { + "1": "right", + "3": "front", + "4": "threeQuartersFrontLeft", + "5": "threeQuartersRearLeft", + "6": "rear", + "7": "left", +} + + +async def async_setup_services(hass: HomeAssistant) -> None: + """Set up services.""" + + hass.services.async_register( + DOMAIN, + SERVICE_GET_IMAGE_URL, + _get_image_url, + schema=SERVICE_GET_IMAGE_URL_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + + +async def _get_image_url(call: ServiceCall) -> dict[str, Any]: + entry_id = call.data.get(CONF_CONFIG_ENTRY_ID, "") + requested_images = call.data.get(CONF_IMAGE_TYPES, []) + + entry = _async_get_config_entry(call.hass, entry_id) + image_types = _get_requested_image_types(requested_images) + client = get_async_client(call.hass) + + # Build (type, url) pairs for all requested image types up front + candidates: list[tuple[str, str]] = [] + + for image_type in image_types: + if image_type == "interior": + url = entry.runtime_data.context.vehicle.images.internal_image_url or "" + else: + url = _parse_exterior_image_url( + entry.runtime_data.context.vehicle.images.exterior_image_url, + _PARAM_IMAGE_ANGLE_MAP[image_type], + ) + + candidates.append((image_type, url)) + + # Interior images exist if their URL is populated; exterior images require an HTTP check + async def _check_exists(image_type: str, url: str) -> bool: + if image_type == "interior": + return bool(url) + return await _async_image_exists(client, url) + + # Run checks in parallel + exists_results = await asyncio.gather( + *(_check_exists(image_type, url) for image_type, url in candidates) + ) + + return { + "images": [ + {"type": image_type, "url": url} + for (image_type, url), exists in zip( + candidates, exists_results, strict=True + ) + if exists + ] + } + + +def _async_get_config_entry(hass: HomeAssistant, entry_id: str) -> VolvoConfigEntry: + if not entry_id: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_entry_id", + translation_placeholders={"entry_id": entry_id}, + ) + + if not (entry := hass.config_entries.async_get_entry(entry_id)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="entry_not_found", + translation_placeholders={"entry_id": entry_id}, + ) + + if entry.domain != DOMAIN: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_entry", + translation_placeholders={"entry_id": entry.entry_id}, + ) + + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="entry_not_loaded", + translation_placeholders={"entry_id": entry.entry_id}, + ) + + return entry + + +def _get_requested_image_types(requested_image_types: list[str]) -> list[str]: + allowed_image_types = [*_PARAM_IMAGE_ANGLE_MAP.keys(), "interior"] + + if not requested_image_types: + return allowed_image_types + + image_types: list[str] = [] + + for image_type in requested_image_types: + if image_type in image_types: + continue + + if image_type not in allowed_image_types: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_image_type", + translation_placeholders={"image_type": image_type}, + ) + + image_types.append(image_type) + + return image_types + + +def _parse_exterior_image_url(exterior_url: str, angle: str) -> str: + if not exterior_url: + return "" + + url_parts = parse.urlparse(exterior_url) + + if url_parts.netloc.startswith("wizz"): + if new_angle := _IMAGE_ANGLE_MAP.get(angle): + current_angle = url_parts.path.split("/")[-2] + return exterior_url.replace(current_angle, new_angle) + + return "" + + query = parse.parse_qs(url_parts.query, keep_blank_values=True) + query["angle"] = [angle] + + return url_parts._replace(query=parse.urlencode(query, doseq=True)).geturl() + + +async def _async_image_exists(client: AsyncClient, url: str) -> bool: + if not url: + return False + + try: + async with client.stream( + "GET", url, headers=_HEADERS, timeout=10, follow_redirects=True + ) as response: + response.raise_for_status() + except HTTPStatusError as ex: + status = ex.response.status_code if ex.response is not None else None + + if status in (404, 410): + _LOGGER.debug("Image does not exist: %s", url) + return False + + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="image_error", + translation_placeholders={"url": url}, + ) from ex + except HTTPError as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="image_error", + translation_placeholders={"url": url}, + ) from ex + else: + return True diff --git a/homeassistant/components/volvo/services.yaml b/homeassistant/components/volvo/services.yaml new file mode 100644 index 0000000000000..b128eff785afc --- /dev/null +++ b/homeassistant/components/volvo/services.yaml @@ -0,0 +1,24 @@ +get_image_url: + fields: + entry: + required: true + selector: + config_entry: + integration: volvo + images: + required: false + selector: + select: + translation_key: service_param_image + multiple: true + sort: true + options: + - exterior_back + - exterior_back_left + - exterior_back_right + - exterior_front + - exterior_front_left + - exterior_front_right + - exterior_side_left + - exterior_side_right + - interior diff --git a/homeassistant/components/volvo/strings.json b/homeassistant/components/volvo/strings.json index 5360f06634e88..f404c4f921642 100644 --- a/homeassistant/components/volvo/strings.json +++ b/homeassistant/components/volvo/strings.json @@ -363,6 +363,24 @@ "command_failure": { "message": "Command {command} failed. Status: {status}. Message: {message}" }, + "entry_not_found": { + "message": "Entry not found: {entry_id}" + }, + "entry_not_loaded": { + "message": "Entry not loaded: {entry_id}" + }, + "image_error": { + "message": "Unable to load vehicle image from: {url}" + }, + "invalid_entry": { + "message": "Invalid entry: {entry_id}" + }, + "invalid_entry_id": { + "message": "Invalid entry ID: {entry_id}" + }, + "invalid_image_type": { + "message": "Invalid image type: {image_type}" + }, "no_vehicle": { "message": "Unable to retrieve vehicle details." }, @@ -375,5 +393,36 @@ "update_failed": { "message": "Unable to update data." } + }, + "selector": { + "service_param_image": { + "options": { + "exterior_back": "Exterior back", + "exterior_back_left": "Exterior back left", + "exterior_back_right": "Exterior back right", + "exterior_front": "Exterior front", + "exterior_front_left": "Exterior front left", + "exterior_front_right": "Exterior front right", + "exterior_side_left": "Exterior side left", + "exterior_side_right": "Exterior side right", + "interior": "Interior" + } + } + }, + "services": { + "get_image_url": { + "description": "Get the URL for one or more vehicle-specific images.", + "fields": { + "entry": { + "description": "The entry to retrieve the vehicle images for.", + "name": "Entry" + }, + "images": { + "description": "The image types to retrieve. Leave empty to get all images.", + "name": "Images" + } + }, + "name": "Get image URL" + } } } diff --git a/homeassistant/components/zinvolt/__init__.py b/homeassistant/components/zinvolt/__init__.py index bd20e4f96672a..ad85d27ce8b45 100644 --- a/homeassistant/components/zinvolt/__init__.py +++ b/homeassistant/components/zinvolt/__init__.py @@ -14,7 +14,11 @@ from .coordinator import ZinvoltConfigEntry, ZinvoltDeviceCoordinator -_PLATFORMS: list[Platform] = [Platform.SENSOR] +_PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.NUMBER, + Platform.SENSOR, +] async def async_setup_entry(hass: HomeAssistant, entry: ZinvoltConfigEntry) -> bool: diff --git a/homeassistant/components/zinvolt/binary_sensor.py b/homeassistant/components/zinvolt/binary_sensor.py new file mode 100644 index 0000000000000..2ba73f5ea6b16 --- /dev/null +++ b/homeassistant/components/zinvolt/binary_sensor.py @@ -0,0 +1,71 @@ +"""Binary sensor platform for Zinvolt integration.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from zinvolt.models import BatteryState + +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 ZinvoltConfigEntry, ZinvoltDeviceCoordinator +from .entity import ZinvoltEntity + + +@dataclass(kw_only=True, frozen=True) +class ZinvoltBatteryStateDescription(BinarySensorEntityDescription): + """Binary sensor description for Zinvolt battery state.""" + + is_on_fn: Callable[[BatteryState], bool] + + +SENSORS: tuple[ZinvoltBatteryStateDescription, ...] = ( + ZinvoltBatteryStateDescription( + key="on_grid", + translation_key="on_grid", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + is_on_fn=lambda state: state.current_power.on_grid, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ZinvoltConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Initialize the entries.""" + + async_add_entities( + ZinvoltBatteryStateBinarySensor(coordinator, description) + for description in SENSORS + for coordinator in entry.runtime_data.values() + ) + + +class ZinvoltBatteryStateBinarySensor(ZinvoltEntity, BinarySensorEntity): + """Zinvolt battery state binary sensor.""" + + entity_description: ZinvoltBatteryStateDescription + + def __init__( + self, + coordinator: ZinvoltDeviceCoordinator, + description: ZinvoltBatteryStateDescription, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.data.serial_number}.{description.key}" + + @property + def is_on(self) -> bool: + """Return the state of the binary sensor.""" + return self.entity_description.is_on_fn(self.coordinator.data) diff --git a/homeassistant/components/zinvolt/coordinator.py b/homeassistant/components/zinvolt/coordinator.py index b495af767985e..4eac4df298d2e 100644 --- a/homeassistant/components/zinvolt/coordinator.py +++ b/homeassistant/components/zinvolt/coordinator.py @@ -36,13 +36,13 @@ def __init__( name=f"Zinvolt {battery_id}", update_interval=timedelta(minutes=5), ) - self._battery_id = battery_id - self._client = client + self.battery_id = battery_id + self.client = client async def _async_update_data(self) -> BatteryState: """Update data from Zinvolt.""" try: - return await self._client.get_battery_status(self._battery_id) + return await self.client.get_battery_status(self.battery_id) except ZinvoltError as err: raise UpdateFailed( translation_key="update_failed", diff --git a/homeassistant/components/zinvolt/entity.py b/homeassistant/components/zinvolt/entity.py new file mode 100644 index 0000000000000..32238868e8e9f --- /dev/null +++ b/homeassistant/components/zinvolt/entity.py @@ -0,0 +1,23 @@ +"""Base entity for Zinvolt integration.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import ZinvoltDeviceCoordinator + + +class ZinvoltEntity(CoordinatorEntity[ZinvoltDeviceCoordinator]): + """Base entity for Zinvolt integration.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: ZinvoltDeviceCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.data.serial_number)}, + manufacturer="Zinvolt", + name=coordinator.data.name, + serial_number=coordinator.data.serial_number, + ) diff --git a/homeassistant/components/zinvolt/manifest.json b/homeassistant/components/zinvolt/manifest.json index c50e82cf41366..c0be07030c60b 100644 --- a/homeassistant/components/zinvolt/manifest.json +++ b/homeassistant/components/zinvolt/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["zinvolt"], "quality_scale": "bronze", - "requirements": ["zinvolt==0.1.0"] + "requirements": ["zinvolt==0.3.0"] } diff --git a/homeassistant/components/zinvolt/number.py b/homeassistant/components/zinvolt/number.py new file mode 100644 index 0000000000000..590b172e1a216 --- /dev/null +++ b/homeassistant/components/zinvolt/number.py @@ -0,0 +1,130 @@ +"""Number platform for Zinvolt integration.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from zinvolt import ZinvoltClient +from zinvolt.models import BatteryState + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfPower, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import ZinvoltConfigEntry, ZinvoltDeviceCoordinator +from .entity import ZinvoltEntity + + +@dataclass(kw_only=True, frozen=True) +class ZinvoltBatteryStateDescription(NumberEntityDescription): + """Number description for Zinvolt battery state.""" + + max_fn: Callable[[BatteryState], int] | None = None + value_fn: Callable[[BatteryState], int] + set_value_fn: Callable[[ZinvoltClient, str, int], Awaitable[None]] + + +NUMBERS: tuple[ZinvoltBatteryStateDescription, ...] = ( + ZinvoltBatteryStateDescription( + key="max_output", + translation_key="max_output", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + value_fn=lambda state: state.global_settings.max_output, + set_value_fn=lambda client, battery_id, value: client.set_max_output( + battery_id, value + ), + native_min_value=0, + max_fn=lambda state: state.global_settings.max_output_limit, + ), + ZinvoltBatteryStateDescription( + key="upper_threshold", + translation_key="upper_threshold", + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda state: state.global_settings.battery_upper_threshold, + set_value_fn=lambda client, battery_id, value: client.set_upper_threshold( + battery_id, value + ), + native_min_value=0, + native_max_value=100, + ), + ZinvoltBatteryStateDescription( + key="lower_threshold", + translation_key="lower_threshold", + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda state: state.global_settings.battery_lower_threshold, + set_value_fn=lambda client, battery_id, value: client.set_lower_threshold( + battery_id, value + ), + native_min_value=9, + native_max_value=100, + ), + ZinvoltBatteryStateDescription( + key="standby_time", + translation_key="standby_time", + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=NumberDeviceClass.DURATION, + value_fn=lambda state: state.global_settings.standby_time, + set_value_fn=lambda client, battery_id, value: client.set_standby_time( + battery_id, value + ), + native_min_value=5, + native_max_value=60, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ZinvoltConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Initialize the entries.""" + + async_add_entities( + ZinvoltBatteryStateNumber(coordinator, description) + for description in NUMBERS + for coordinator in entry.runtime_data.values() + ) + + +class ZinvoltBatteryStateNumber(ZinvoltEntity, NumberEntity): + """Zinvolt number.""" + + entity_description: ZinvoltBatteryStateDescription + + def __init__( + self, + coordinator: ZinvoltDeviceCoordinator, + description: ZinvoltBatteryStateDescription, + ) -> None: + """Initialize the number.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.data.serial_number}.{description.key}" + + @property + def native_max_value(self) -> float: + """Return the native maximum value.""" + if self.entity_description.max_fn is None: + return super().native_max_value + return self.entity_description.max_fn(self.coordinator.data) + + @property + def native_value(self) -> float: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) + + async def async_set_native_value(self, value: float) -> None: + """Set the state of the sensor.""" + await self.entity_description.set_value_fn( + self.coordinator.client, self.coordinator.battery_id, int(value) + ) diff --git a/homeassistant/components/zinvolt/sensor.py b/homeassistant/components/zinvolt/sensor.py index 3084783be6bb9..796d241ad5e4f 100644 --- a/homeassistant/components/zinvolt/sensor.py +++ b/homeassistant/components/zinvolt/sensor.py @@ -12,12 +12,10 @@ ) from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN from .coordinator import ZinvoltConfigEntry, ZinvoltDeviceCoordinator +from .entity import ZinvoltEntity @dataclass(kw_only=True, frozen=True) @@ -52,12 +50,9 @@ async def async_setup_entry( ) -class ZinvoltBatteryStateSensor( - CoordinatorEntity[ZinvoltDeviceCoordinator], SensorEntity -): +class ZinvoltBatteryStateSensor(ZinvoltEntity, SensorEntity): """Zinvolt battery state sensor.""" - _attr_has_entity_name = True entity_description: ZinvoltBatteryStateDescription def __init__( @@ -69,12 +64,6 @@ def __init__( super().__init__(coordinator) self.entity_description = description self._attr_unique_id = f"{coordinator.data.serial_number}.{description.key}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.data.serial_number)}, - manufacturer="Zinvolt", - name=coordinator.data.name, - serial_number=coordinator.data.serial_number, - ) @property def native_value(self) -> float: diff --git a/homeassistant/components/zinvolt/strings.json b/homeassistant/components/zinvolt/strings.json index 62b36f97b5fbb..fe06fac602d7f 100644 --- a/homeassistant/components/zinvolt/strings.json +++ b/homeassistant/components/zinvolt/strings.json @@ -21,6 +21,27 @@ } } }, + "entity": { + "binary_sensor": { + "on_grid": { + "name": "Grid connection" + } + }, + "number": { + "lower_threshold": { + "name": "Minimum charge level" + }, + "max_output": { + "name": "Maximum output" + }, + "standby_time": { + "name": "Standby time" + }, + "upper_threshold": { + "name": "Maximum charge level" + } + } + }, "exceptions": { "update_failed": { "message": "An error occurred while updating the Zinvolt integration." diff --git a/pylint/plugins/hass_enforce_sorted_platforms.py b/pylint/plugins/hass_enforce_sorted_platforms.py index aa6a6c16efa8e..5ae26a179c9d5 100644 --- a/pylint/plugins/hass_enforce_sorted_platforms.py +++ b/pylint/plugins/hass_enforce_sorted_platforms.py @@ -36,7 +36,7 @@ def _do_sorted_check( """Check for sorted PLATFORMS const.""" if ( isinstance(target, nodes.AssignName) - and target.name == "PLATFORMS" + and target.name in {"PLATFORMS", "_PLATFORMS"} and isinstance(node.value, nodes.List) ): platforms = [v.as_string() for v in node.value.elts] diff --git a/requirements_all.txt b/requirements_all.txt index 7a0dfa0704974..f42ac4f90452e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2530,7 +2530,7 @@ python-awair==0.2.5 python-blockchain-api==0.0.2 # homeassistant.components.bsblan -python-bsblan==5.0.1 +python-bsblan==5.1.0 # homeassistant.components.citybikes python-citybikes==0.3.3 @@ -3120,6 +3120,9 @@ ttls==1.8.3 # homeassistant.components.thethingsnetwork ttn_client==1.2.3 +# homeassistant.components.tuya +tuya-device-handlers==0.0.10 + # homeassistant.components.tuya tuya-device-sharing-sdk==0.2.8 @@ -3353,7 +3356,7 @@ zhong-hong-hvac==1.0.13 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zinvolt -zinvolt==0.1.0 +zinvolt==0.3.0 # homeassistant.components.zoneminder zm-py==0.5.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5c00be733f5c9..f66427bdae953 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2153,7 +2153,7 @@ python-MotionMount==2.3.0 python-awair==0.2.5 # homeassistant.components.bsblan -python-bsblan==5.0.1 +python-bsblan==5.1.0 # homeassistant.components.ecobee python-ecobee-api==0.3.2 @@ -2620,6 +2620,9 @@ ttls==1.8.3 # homeassistant.components.thethingsnetwork ttn_client==1.2.3 +# homeassistant.components.tuya +tuya-device-handlers==0.0.10 + # homeassistant.components.tuya tuya-device-sharing-sdk==0.2.8 @@ -2817,7 +2820,7 @@ zeversolar==0.3.2 zha==1.0.0 # homeassistant.components.zinvolt -zinvolt==0.1.0 +zinvolt==0.3.0 # homeassistant.components.zoneminder zm-py==0.5.4 diff --git a/tests/components/aladdin_connect/snapshots/test_cover.ambr b/tests/components/aladdin_connect/snapshots/test_cover.ambr new file mode 100644 index 0000000000000..d9d9ff8ace614 --- /dev/null +++ b/tests/components/aladdin_connect/snapshots/test_cover.ambr @@ -0,0 +1,52 @@ +# serializer version: 1 +# name: test_cover_entities[cover.test_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'aladdin_connect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'test_device_id-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_entities[cover.test_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'garage', + 'friendly_name': 'Test Door', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- diff --git a/tests/components/aladdin_connect/snapshots/test_sensor.ambr b/tests/components/aladdin_connect/snapshots/test_sensor.ambr new file mode 100644 index 0000000000000..7f888c1f55476 --- /dev/null +++ b/tests/components/aladdin_connect/snapshots/test_sensor.ambr @@ -0,0 +1,55 @@ +# serializer version: 1 +# name: test_sensor_entities[sensor.test_door_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_door_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'aladdin_connect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test_device_id-1-battery_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_entities[sensor.test_door_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Test Door Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_door_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- diff --git a/tests/components/aladdin_connect/test_cover.py b/tests/components/aladdin_connect/test_cover.py new file mode 100644 index 0000000000000..173c07363718b --- /dev/null +++ b/tests/components/aladdin_connect/test_cover.py @@ -0,0 +1,135 @@ +"""Tests for the Aladdin Connect cover platform.""" + +from unittest.mock import AsyncMock, patch + +import aiohttp +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +ENTITY_ID = "cover.test_door" + + +async def _setup(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Set up integration with only the cover platform.""" + with patch("homeassistant.components.aladdin_connect.PLATFORMS", [Platform.COVER]): + await init_integration(hass, entry) + + +async def test_cover_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the cover entity states and attributes.""" + await _setup(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_open_cover( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_aladdin_connect_api: AsyncMock, +) -> None: + """Test opening the cover.""" + await _setup(hass, mock_config_entry) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_aladdin_connect_api.open_door.assert_called_once_with("test_device_id", 1) + + +async def test_close_cover( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_aladdin_connect_api: AsyncMock, +) -> None: + """Test closing the cover.""" + await _setup(hass, mock_config_entry) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_aladdin_connect_api.close_door.assert_called_once_with("test_device_id", 1) + + +@pytest.mark.parametrize( + ("status", "expected_closed", "expected_opening", "expected_closing"), + [ + ("closed", True, False, False), + ("open", False, False, False), + ("opening", False, True, False), + ("closing", False, False, True), + ], +) +async def test_cover_states( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_aladdin_connect_api: AsyncMock, + status: str, + expected_closed: bool, + expected_opening: bool, + expected_closing: bool, +) -> None: + """Test cover state properties.""" + mock_aladdin_connect_api.get_doors.return_value[0].status = status + await _setup(hass, mock_config_entry) + state = hass.states.get(ENTITY_ID) + assert state is not None + assert (state.state == "closed") == expected_closed + assert (state.state == "opening") == expected_opening + assert (state.state == "closing") == expected_closing + + +async def test_cover_none_status( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_aladdin_connect_api: AsyncMock, +) -> None: + """Test cover state when status is None.""" + mock_aladdin_connect_api.get_doors.return_value[0].status = None + await _setup(hass, mock_config_entry) + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == "unknown" + + +async def test_cover_unavailable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_aladdin_connect_api: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test cover becomes unavailable when coordinator update fails.""" + await _setup(hass, mock_config_entry) + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state != STATE_UNAVAILABLE + + mock_aladdin_connect_api.update_door.side_effect = aiohttp.ClientError() + freezer.tick(15) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/aladdin_connect/test_sensor.py b/tests/components/aladdin_connect/test_sensor.py new file mode 100644 index 0000000000000..7f0b9a2601626 --- /dev/null +++ b/tests/components/aladdin_connect/test_sensor.py @@ -0,0 +1,59 @@ +"""Tests for the Aladdin Connect sensor platform.""" + +from unittest.mock import AsyncMock, patch + +import aiohttp +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +ENTITY_ID = "sensor.test_door_battery" + + +async def _setup(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Set up integration with only the sensor platform.""" + with patch("homeassistant.components.aladdin_connect.PLATFORMS", [Platform.SENSOR]): + await init_integration(hass, entry) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the sensor entity states and attributes.""" + await _setup(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_unavailable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_aladdin_connect_api: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test sensor becomes unavailable when coordinator update fails.""" + await _setup(hass, mock_config_entry) + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state != STATE_UNAVAILABLE + + mock_aladdin_connect_api.update_door.side_effect = aiohttp.ClientError() + freezer.tick(15) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py index 3f13cffb809de..4945cddf9c9af 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -14,6 +14,7 @@ import voluptuous as vol from homeassistant.components.calendar import ( + CREATE_EVENT_SERVICE, DOMAIN, SERVICE_GET_EVENTS, CalendarEntity, @@ -23,7 +24,6 @@ from homeassistant.exceptions import HomeAssistantError, ServiceNotSupported from homeassistant.helpers import entity_registry as er from homeassistant.helpers.typing import UNDEFINED -from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from .conftest import MockCalendarEntity, MockConfigEntry @@ -224,7 +224,6 @@ async def test_unsupported_websocket( async def test_unsupported_create_event_service(hass: HomeAssistant) -> None: """Test unsupported service call.""" - await async_setup_component(hass, "homeassistant", {}) with pytest.raises( ServiceNotSupported, match="Entity calendar.calendar_1 does not " @@ -232,7 +231,7 @@ async def test_unsupported_create_event_service(hass: HomeAssistant) -> None: ): await hass.services.async_call( DOMAIN, - "create_event", + CREATE_EVENT_SERVICE, { "start_date_time": "1997-07-14T17:00:00+00:00", "end_date_time": "1997-07-15T04:00:00+00:00", @@ -407,8 +406,8 @@ async def test_create_event_service_invalid_params( with pytest.raises(expected_error, match=error_match): await hass.services.async_call( - "calendar", - "create_event", + DOMAIN, + CREATE_EVENT_SERVICE, { "summary": "Bastille Day Party", **date_fields, diff --git a/tests/components/counter/test_init.py b/tests/components/counter/test_init.py index c5595d7fcbe65..61f63f4a6e9fc 100644 --- a/tests/components/counter/test_init.py +++ b/tests/components/counter/test_init.py @@ -103,7 +103,7 @@ async def test_config_options(hass: HomeAssistant) -> None: } } - assert await async_setup_component(hass, "counter", config) + assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() _LOGGER.debug("ENTITIES: %s", hass.states.async_entity_ids()) @@ -135,7 +135,7 @@ async def test_methods(hass: HomeAssistant) -> None: """Test increment, decrement, set value, and reset methods.""" config = {DOMAIN: {"test_1": {}}} - assert await async_setup_component(hass, "counter", config) + assert await async_setup_component(hass, DOMAIN, config) entity_id = "counter.test_1" @@ -193,7 +193,7 @@ async def test_methods_with_config(hass: HomeAssistant) -> None: } } - assert await async_setup_component(hass, "counter", config) + assert await async_setup_component(hass, DOMAIN, config) entity_id = "counter.test" @@ -347,15 +347,15 @@ async def test_no_initial_state_and_no_restore_state(hass: HomeAssistant) -> Non async def test_counter_context(hass: HomeAssistant, hass_admin_user: MockUser) -> None: """Test that counter context works.""" - assert await async_setup_component(hass, "counter", {"counter": {"test": {}}}) + assert await async_setup_component(hass, DOMAIN, {"counter": {"test": {}}}) state = hass.states.get("counter.test") assert state is not None await hass.services.async_call( - "counter", + DOMAIN, "increment", - {"entity_id": state.entity_id}, + {ATTR_ENTITY_ID: state.entity_id}, True, Context(user_id=hass_admin_user.id), ) @@ -369,7 +369,7 @@ async def test_counter_context(hass: HomeAssistant, hass_admin_user: MockUser) - async def test_counter_min(hass: HomeAssistant, hass_admin_user: MockUser) -> None: """Test that min works.""" assert await async_setup_component( - hass, "counter", {"counter": {"test": {"minimum": "0", "initial": "0"}}} + hass, DOMAIN, {"counter": {"test": {"minimum": "0", "initial": "0"}}} ) state = hass.states.get("counter.test") @@ -377,9 +377,9 @@ async def test_counter_min(hass: HomeAssistant, hass_admin_user: MockUser) -> No assert state.state == "0" await hass.services.async_call( - "counter", + DOMAIN, "decrement", - {"entity_id": state.entity_id}, + {ATTR_ENTITY_ID: state.entity_id}, True, Context(user_id=hass_admin_user.id), ) @@ -389,9 +389,9 @@ async def test_counter_min(hass: HomeAssistant, hass_admin_user: MockUser) -> No assert state2.state == "0" await hass.services.async_call( - "counter", + DOMAIN, "increment", - {"entity_id": state.entity_id}, + {ATTR_ENTITY_ID: state.entity_id}, True, Context(user_id=hass_admin_user.id), ) @@ -404,7 +404,7 @@ async def test_counter_min(hass: HomeAssistant, hass_admin_user: MockUser) -> No async def test_counter_max(hass: HomeAssistant, hass_admin_user: MockUser) -> None: """Test that max works.""" assert await async_setup_component( - hass, "counter", {"counter": {"test": {"maximum": "0", "initial": "0"}}} + hass, DOMAIN, {"counter": {"test": {"maximum": "0", "initial": "0"}}} ) state = hass.states.get("counter.test") @@ -412,9 +412,9 @@ async def test_counter_max(hass: HomeAssistant, hass_admin_user: MockUser) -> No assert state.state == "0" await hass.services.async_call( - "counter", + DOMAIN, "increment", - {"entity_id": state.entity_id}, + {ATTR_ENTITY_ID: state.entity_id}, True, Context(user_id=hass_admin_user.id), ) @@ -424,9 +424,9 @@ async def test_counter_max(hass: HomeAssistant, hass_admin_user: MockUser) -> No assert state2.state == "0" await hass.services.async_call( - "counter", + DOMAIN, "decrement", - {"entity_id": state.entity_id}, + {ATTR_ENTITY_ID: state.entity_id}, True, Context(user_id=hass_admin_user.id), ) diff --git a/tests/components/hassio/test_http.py b/tests/components/hassio/test_http.py index 98dda1c8fe979..c9768d8581cd3 100644 --- a/tests/components/hassio/test_http.py +++ b/tests/components/hassio/test_http.py @@ -528,6 +528,32 @@ async def test_no_follow_logs_compress( assert resp2.headers.get("Content-Encoding") == "deflate" +async def test_no_event_stream_compress( + hassio_client: TestClient, aioclient_mock: AiohttpClientMocker +) -> None: + """Test that we do not compress SSE (Server-Sent Events) streams.""" + aioclient_mock.get( + "http://127.0.0.1/app/events", + headers={"Content-Type": "text/event-stream"}, + ) + aioclient_mock.get( + "http://127.0.0.1/app/data", + headers={"Content-Type": "application/json"}, + ) + + resp1 = await hassio_client.get("/api/hassio/app/events") + resp2 = await hassio_client.get("/api/hassio/app/data") + + # Check we got right response + assert resp1.status == HTTPStatus.OK + # SSE (text/event-stream) should not be compressed to allow streaming + assert resp1.headers.get("Content-Encoding") is None + + assert resp2.status == HTTPStatus.OK + # Regular JSON should be compressed + assert resp2.headers.get("Content-Encoding") == "deflate" + + async def test_forward_range_header_for_logs( hassio_client: TestClient, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/iron_os/snapshots/test_update.ambr b/tests/components/iron_os/snapshots/test_update.ambr index 64c4e692071c8..ae53c7c120511 100644 --- a/tests/components/iron_os/snapshots/test_update.ambr +++ b/tests/components/iron_os/snapshots/test_update.ambr @@ -14,7 +14,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'update', - 'entity_category': , + 'entity_category': , 'entity_id': 'update.pinecil_firmware', 'has_entity_name': True, 'hidden_by': None, diff --git a/tests/components/matter/snapshots/test_vacuum.ambr b/tests/components/matter/snapshots/test_vacuum.ambr index aacdc2525ff37..73eb2d2388e99 100644 --- a/tests/components/matter/snapshots/test_vacuum.ambr +++ b/tests/components/matter/snapshots/test_vacuum.ambr @@ -29,7 +29,7 @@ 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'vacuum', 'unique_id': '00000000000004D2-000000000000002F-MatterNodeDevice-1-MatterVacuumCleaner-84-1', 'unit_of_measurement': None, @@ -39,7 +39,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'ecodeebot', - 'supported_features': , + 'supported_features': , }), 'context': , 'entity_id': 'vacuum.ecodeebot', @@ -79,7 +79,7 @@ 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'vacuum', 'unique_id': '00000000000004D2-0000000000000028-MatterNodeDevice-1-MatterVacuumCleaner-84-1', 'unit_of_measurement': None, @@ -89,7 +89,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': '2BAVS-AB6031X-44PE', - 'supported_features': , + 'supported_features': , }), 'context': , 'entity_id': 'vacuum.2bavs_ab6031x_44pe', @@ -129,7 +129,7 @@ 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'vacuum', 'unique_id': '00000000000004D2-0000000000000042-MatterNodeDevice-1-MatterVacuumCleaner-84-1', 'unit_of_measurement': None, @@ -139,7 +139,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Mock Vacuum', - 'supported_features': , + 'supported_features': , }), 'context': , 'entity_id': 'vacuum.mock_vacuum', @@ -179,7 +179,7 @@ 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'vacuum', 'unique_id': '00000000000004D2-0000000000000061-MatterNodeDevice-1-MatterVacuumCleaner-84-1', 'unit_of_measurement': None, @@ -189,7 +189,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'K11+', - 'supported_features': , + 'supported_features': , }), 'context': , 'entity_id': 'vacuum.k11', diff --git a/tests/components/matter/snapshots/test_valve.ambr b/tests/components/matter/snapshots/test_valve.ambr index c0a6b8e6e5c88..91ac91f845e5e 100644 --- a/tests/components/matter/snapshots/test_valve.ambr +++ b/tests/components/matter/snapshots/test_valve.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_valves[mock_valve][valve.mock_valve-entry] +# name: test_valves[mock_valve][mock_valve][valve.mock_valve-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -35,7 +35,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_valves[mock_valve][valve.mock_valve-state] +# name: test_valves[mock_valve][mock_valve][valve.mock_valve-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'water', diff --git a/tests/components/matter/test_vacuum.py b/tests/components/matter/test_vacuum.py index b18ab5311ba4c..b4434cfc651ae 100644 --- a/tests/components/matter/test_vacuum.py +++ b/tests/components/matter/test_vacuum.py @@ -7,10 +7,11 @@ import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component from .common import ( @@ -19,6 +20,8 @@ trigger_subscription_callback, ) +from tests.typing import WebSocketGenerator + @pytest.mark.usefixtures("matter_devices") async def test_vacuum( @@ -71,8 +74,13 @@ async def test_vacuum_actions( blocking=True, ) - assert matter_client.send_device_command.call_count == 1 - assert matter_client.send_device_command.call_args == call( + assert matter_client.send_device_command.call_count == 2 + assert matter_client.send_device_command.call_args_list[0] == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.ServiceArea.Commands.SelectAreas(newAreas=[]), + ) + assert matter_client.send_device_command.call_args_list[1] == call( node_id=matter_node.node_id, endpoint_id=1, command=clusters.RvcRunMode.Commands.ChangeToMode(newMode=1), @@ -289,5 +297,185 @@ async def test_vacuum_actions_no_supported_run_modes( blocking=True, ) + component = hass.data["vacuum"] + entity = component.get_entity(entity_id) + assert entity is not None + + with pytest.raises( + HomeAssistantError, + match="No supported run mode found to start the vacuum cleaner", + ): + await entity.async_clean_segments(["7"]) + # Ensure no commands were sent to the device assert matter_client.send_device_command.call_count == 0 + + +@pytest.mark.parametrize("node_fixture", ["mock_vacuum_cleaner"]) +async def test_vacuum_get_segments( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test vacuum get_segments websocket command.""" + await async_setup_component(hass, "homeassistant", {}) + entity_id = "vacuum.mock_vacuum" + state = hass.states.get(entity_id) + assert state + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + {"type": "vacuum/get_segments", "entity_id": entity_id} + ) + + msg = await client.receive_json() + assert msg["success"] + segments = msg["result"]["segments"] + assert len(segments) == 3 + assert segments[0] == {"id": "7", "name": "My Location A", "group": None} + assert segments[1] == {"id": "1234567", "name": "My Location B", "group": None} + assert segments[2] == {"id": "2290649224", "name": "My Location C", "group": None} + + +@pytest.mark.parametrize("node_fixture", ["mock_vacuum_cleaner"]) +async def test_vacuum_clean_area( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test vacuum clean_area service action.""" + await async_setup_component(hass, "homeassistant", {}) + entity_id = "vacuum.mock_vacuum" + state = hass.states.get(entity_id) + assert state + + # Set up area_mapping so the service can map area IDs to segment IDs + entity_registry.async_update_entity_options( + entity_id, + VACUUM_DOMAIN, + { + "area_mapping": {"area_1": ["7", "1234567"]}, + "last_seen_segments": [ + {"id": "7", "name": "My Location A", "group": None}, + {"id": "1234567", "name": "My Location B", "group": None}, + {"id": "2290649224", "name": "My Location C", "group": None}, + ], + }, + ) + + # Mock a successful SelectAreasResponse + matter_client.send_device_command.return_value = ( + clusters.ServiceArea.Commands.SelectAreasResponse( + status=clusters.ServiceArea.Enums.SelectAreasStatus.kSuccess, + ) + ) + + await hass.services.async_call( + VACUUM_DOMAIN, + "clean_area", + {"entity_id": entity_id, "cleaning_area_id": ["area_1"]}, + blocking=True, + ) + + # Verify both commands were sent: SelectAreas followed by ChangeToMode + assert matter_client.send_device_command.call_count == 2 + assert matter_client.send_device_command.call_args_list[0] == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.ServiceArea.Commands.SelectAreas(newAreas=[7, 1234567]), + ) + assert matter_client.send_device_command.call_args_list[1] == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.RvcRunMode.Commands.ChangeToMode(newMode=1), + ) + + +@pytest.mark.parametrize("node_fixture", ["mock_vacuum_cleaner"]) +async def test_vacuum_clean_area_select_areas_failure( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test vacuum clean_area raises error when SelectAreas fails.""" + await async_setup_component(hass, "homeassistant", {}) + entity_id = "vacuum.mock_vacuum" + state = hass.states.get(entity_id) + assert state + + # Set up area_mapping so the service can map area IDs to segment IDs + entity_registry.async_update_entity_options( + entity_id, + VACUUM_DOMAIN, + { + "area_mapping": {"area_1": ["7", "1234567"]}, + "last_seen_segments": [ + {"id": "7", "name": "My Location A", "group": None}, + {"id": "1234567", "name": "My Location B", "group": None}, + {"id": "2290649224", "name": "My Location C", "group": None}, + ], + }, + ) + + # Mock a failed SelectAreasResponse + matter_client.send_device_command.return_value = ( + clusters.ServiceArea.Commands.SelectAreasResponse( + status=clusters.ServiceArea.Enums.SelectAreasStatus.kUnsupportedArea, + statusText="Area 7 not supported", + ) + ) + + with pytest.raises(HomeAssistantError, match="Failed to select areas"): + await hass.services.async_call( + VACUUM_DOMAIN, + "clean_area", + {"entity_id": entity_id, "cleaning_area_id": ["area_1"]}, + blocking=True, + ) + + # Verify only SelectAreas was sent, ChangeToMode should NOT be sent + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.ServiceArea.Commands.SelectAreas(newAreas=[7, 1234567]), + ) + + +@pytest.mark.parametrize("node_fixture", ["mock_vacuum_cleaner"]) +async def test_vacuum_raise_segments_changed_issue( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test that issue is raised on segments change.""" + entity_id = "vacuum.mock_vacuum" + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry is not None + + entity_registry.async_update_entity_options( + entity_id, + VACUUM_DOMAIN, + { + "last_seen_segments": [ + { + "id": "7", + "name": "Old location A", + "group": None, + } + ] + }, + ) + + set_node_attribute(matter_node, 1, 97, 4, 0x02) + await trigger_subscription_callback(hass, matter_client) + + issue_reg = ir.async_get(hass) + issue = issue_reg.async_get_issue( + VACUUM_DOMAIN, f"segments_changed_{entity_entry.id}" + ) + assert issue is not None diff --git a/tests/components/matter/test_valve.py b/tests/components/matter/test_valve.py index d72dd2883ebd5..dd484eea87e7a 100644 --- a/tests/components/matter/test_valve.py +++ b/tests/components/matter/test_valve.py @@ -1,5 +1,6 @@ """Test Matter valve.""" +from typing import Any from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters @@ -18,13 +19,21 @@ ) -@pytest.mark.usefixtures("matter_devices") +@pytest.fixture(name="attributes") +def attributes_fixture(request: pytest.FixtureRequest) -> dict[str, Any]: + """Override node attributes for a parametrized test.""" + return getattr(request, "param", {}) + + +@pytest.mark.parametrize("node_fixture", ["mock_valve"]) async def test_valves( hass: HomeAssistant, + matter_node: MatterNode, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Test valves.""" + assert matter_node snapshot_matter_entities(hass, entity_registry, snapshot, Platform.VALVE) @@ -152,3 +161,39 @@ async def test_valve( command=clusters.ValveConfigurationAndControl.Commands.Close(), ) matter_client.send_device_command.reset_mock() + + +@pytest.mark.parametrize("node_fixture", ["mock_valve"]) +@pytest.mark.parametrize( + "attributes", + [{"1/129/4": None, "1/129/5": None}], + indirect=True, +) +async def test_valve_discovery_with_nullable_states( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test valve discovery when CurrentState and TargetState are nullable.""" + assert matter_node.node_id == 60 + + state = hass.states.get("valve.mock_valve") + assert state + assert state.state == "unknown" + assert state.attributes["friendly_name"] == "Mock Valve" + + await hass.services.async_call( + "valve", + "open_valve", + { + "entity_id": "valve.mock_valve", + }, + blocking=True, + ) + + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.ValveConfigurationAndControl.Commands.Open(), + ) diff --git a/tests/components/nrgkick/snapshots/test_diagnostics.ambr b/tests/components/nrgkick/snapshots/test_diagnostics.ambr new file mode 100644 index 0000000000000..264c2d32caf0c --- /dev/null +++ b/tests/components/nrgkick/snapshots/test_diagnostics.ambr @@ -0,0 +1,117 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'coordinator_data': dict({ + 'control': dict({ + 'charge_pause': 0, + 'current_set': 16.0, + 'energy_limit': 0, + 'phase_count': 3, + }), + 'info': dict({ + 'cellular': dict({ + 'mode': 3, + 'operator': 'Test operator', + 'rssi': -85, + }), + 'connector': dict({ + 'max_current': 32.0, + 'phase_count': 3, + 'serial_number': 'CONN123', + 'type': 3, + }), + 'general': dict({ + 'device_name': 'NRGkick Test', + 'json_api_version': 'v1', + 'model_type': 'NRGkick Gen2 SIM', + 'rated_current': 32.0, + 'serial_number': 'TEST123456', + }), + 'grid': dict({ + 'frequency': 50.0, + 'phases': 7, + 'voltage': 230, + }), + 'hardware': dict({ + 'bluetooth_version': '1.2.3', + 'smartmodule_version': '4.0.0.0', + }), + 'network': dict({ + 'ip_address': '192.168.1.100', + 'mac_address': 'AA:BB:CC:DD:EE:FF', + 'wifi_rssi': -45, + 'wifi_ssid': 'TestNetwork', + }), + 'software': dict({ + 'firmware_version': '2.1.0', + }), + }), + 'values': dict({ + 'energy': dict({ + 'charged_energy': 5000, + 'total_charged_energy': 100000, + }), + 'general': dict({ + 'charge_count': 5, + 'charging_rate': 11.0, + 'error_code': 0, + 'rcd_trigger': 0, + 'status': 3, + 'vehicle_charging_time': 50, + 'vehicle_connect_time': 100, + 'warning_code': 0, + }), + 'powerflow': dict({ + 'charging_current': 16.0, + 'charging_voltage': 230.0, + 'grid_frequency': 50.0, + 'l1': dict({ + 'active_power': 3680, + 'apparent_power': 3680, + 'current': 16.0, + 'power_factor': 100, + 'reactive_power': 0, + 'voltage': 230.0, + }), + 'l2': dict({ + 'active_power': 3680, + 'apparent_power': 3680, + 'current': 16.0, + 'power_factor': 100, + 'reactive_power': 0, + 'voltage': 230.0, + }), + 'l3': dict({ + 'active_power': 3680, + 'apparent_power': 3680, + 'current': 16.0, + 'power_factor': 100, + 'reactive_power': 0, + 'voltage': 230.0, + }), + 'n': dict({ + 'current': 0.0, + }), + 'peak_power': 11000, + 'total_active_power': 11000, + 'total_apparent_power': 11040, + 'total_power_factor': 100, + 'total_reactive_power': 0, + }), + 'temperatures': dict({ + 'connector_l1': 28.0, + 'connector_l2': 29.0, + 'connector_l3': 28.5, + 'domestic_plug_1': 25.0, + 'domestic_plug_2': 25.0, + 'housing': 35.0, + }), + }), + }), + 'entry_data': dict({ + 'host': '192.168.1.100', + 'password': '**REDACTED**', + 'username': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/nrgkick/test_diagnostics.py b/tests/components/nrgkick/test_diagnostics.py new file mode 100644 index 0000000000000..c09d8b279c91a --- /dev/null +++ b/tests/components/nrgkick/test_diagnostics.py @@ -0,0 +1,30 @@ +"""Tests for the diagnostics data provided by the NRGkick integration.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + mock_nrgkick_api: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + await setup_integration(hass, mock_config_entry) + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) diff --git a/tests/components/ntfy/conftest.py b/tests/components/ntfy/conftest.py index 45b509c1e8e67..86ab3734bd368 100644 --- a/tests/components/ntfy/conftest.py +++ b/tests/components/ntfy/conftest.py @@ -5,10 +5,11 @@ from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock, Mock, patch -from aiontfy import Account, AccountTokenResponse, Event, Notification +from aiontfy import Account, AccountTokenResponse, Event, Notification, Version +from aiontfy.update import LatestRelease import pytest -from homeassistant.components.ntfy.const import CONF_TOPIC, DOMAIN +from homeassistant.components.ntfy.const import CONF_TOPIC, DEFAULT_URL, DOMAIN from homeassistant.config_entries import ConfigSubentryData from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL @@ -41,6 +42,9 @@ def mock_aiontfy() -> Generator[AsyncMock]: client.generate_token.return_value = AccountTokenResponse( token="token", last_access=datetime.now() ) + client.version.return_value = Version.from_json( + load_fixture("version.json", DOMAIN) + ) resp = Mock( id="h6Y2hKA5sy0U", @@ -90,6 +94,24 @@ async def mock_ws( yield client +@pytest.fixture +def mock_update_checker() -> Generator[AsyncMock]: + """Mock aiontfy update checker.""" + + with patch( + "homeassistant.components.ntfy.UpdateChecker", autospec=True + ) as mock_client: + client = mock_client.return_value + + client.latest_release.return_value = LatestRelease( + tag_name="v2.17.0", + name="v2.17.0", + html_url="https://github.com/binwiederhier/ntfy/releases/tag/v2.17.0", + body="**RELEASE_NOTES**", + ) + yield client + + @pytest.fixture(autouse=True) def mock_random() -> Generator[MagicMock]: """Mock random.""" @@ -108,7 +130,7 @@ def mock_config_entry() -> MockConfigEntry: domain=DOMAIN, title="ntfy.sh", data={ - CONF_URL: "https://ntfy.sh/", + CONF_URL: DEFAULT_URL, CONF_USERNAME: None, CONF_TOKEN: "token", CONF_VERIFY_SSL: True, diff --git a/tests/components/ntfy/fixtures/version.json b/tests/components/ntfy/fixtures/version.json new file mode 100644 index 0000000000000..0a0337e3e786c --- /dev/null +++ b/tests/components/ntfy/fixtures/version.json @@ -0,0 +1,5 @@ +{ + "version": "2.17.0", + "commit": "a03a37feb1869e84e3af0dd6190bdc7183f211ec", + "date": "2026-02-09T21:53:23Z" +} diff --git a/tests/components/ntfy/snapshots/test_update.ambr b/tests/components/ntfy/snapshots/test_update.ambr new file mode 100644 index 0000000000000..ab6abe2644490 --- /dev/null +++ b/tests/components/ntfy/snapshots/test_update.ambr @@ -0,0 +1,62 @@ +# serializer version: 1 +# name: test_setup[update.ntfy_example_ntfy_version-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.ntfy_example_ntfy_version', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'ntfy version', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'ntfy version', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': , + 'unique_id': '123456789_update', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[update.ntfy_example_ntfy_version-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/ntfy/icon.png', + 'friendly_name': 'ntfy.example ntfy version', + 'in_progress': False, + 'installed_version': '2.17.0', + 'latest_version': '2.17.0', + 'release_summary': None, + 'release_url': 'https://github.com/binwiederhier/ntfy/releases/tag/v2.17.0', + 'skipped_version': None, + 'supported_features': , + 'title': 'ntfy v2.17.0', + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.ntfy_example_ntfy_version', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/ntfy/test_update.py b/tests/components/ntfy/test_update.py new file mode 100644 index 0000000000000..95aa6dd68bf93 --- /dev/null +++ b/tests/components/ntfy/test_update.py @@ -0,0 +1,170 @@ +"""Tests for the ntfy update platform.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from aiontfy.exceptions import ( + NtfyNotFoundPageError, + NtfyUnauthorizedAuthenticationError, +) +from aiontfy.update import UpdateCheckerError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.ntfy.const import DEFAULT_URL, DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + CONF_TOKEN, + CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, + STATE_UNAVAILABLE, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform +from tests.typing import WebSocketGenerator + + +@pytest.fixture(autouse=True) +def update_only() -> Generator[None]: + """Enable only the update platform.""" + with patch( + "homeassistant.components.ntfy.PLATFORMS", + [Platform.UPDATE], + ): + yield + + +@pytest.mark.usefixtures("mock_aiontfy", "mock_update_checker") +async def test_setup( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Snapshot test states of update platform.""" + ws_client = await hass_ws_client(hass) + config_entry = MockConfigEntry( + domain=DOMAIN, + title="ntfy.example", + data={ + CONF_URL: "https://ntfy.example/", + CONF_USERNAME: None, + CONF_TOKEN: "token", + CONF_VERIFY_SSL: True, + }, + entry_id="123456789", + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + await ws_client.send_json( + { + "id": 1, + "type": "update/release_notes", + "entity_id": "update.ntfy_example_ntfy_version", + } + ) + result = await ws_client.receive_json() + assert result["result"] == "**RELEASE_NOTES**" + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_update_checker_error( + hass: HomeAssistant, + mock_update_checker: AsyncMock, +) -> None: + """Test update entity update checker error.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="ntfy.example", + data={ + CONF_URL: "https://ntfy.example/", + CONF_USERNAME: None, + CONF_TOKEN: "token", + CONF_VERIFY_SSL: True, + }, + entry_id="123456789", + ) + mock_update_checker.latest_release.side_effect = UpdateCheckerError + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get("update.ntfy_example_ntfy_version") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + "exception", + [ + NtfyUnauthorizedAuthenticationError(40101, 401, "unauthorized"), + NtfyNotFoundPageError(40401, 404, "page not found"), + ], + ids=["not an admin", "version < 2.17.0"], +) +@pytest.mark.usefixtures("mock_update_checker") +async def test_version_errors( + hass: HomeAssistant, + mock_aiontfy: AsyncMock, + exception: Exception, +) -> None: + """Test update entity is not created when version endpoint is not available.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="ntfy.example", + data={ + CONF_URL: "https://ntfy.example/", + CONF_USERNAME: None, + CONF_TOKEN: "token", + CONF_VERIFY_SSL: True, + }, + entry_id="123456789", + ) + mock_aiontfy.version.side_effect = exception + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get("update.ntfy_example_ntfy_version") + assert state is None + + +@pytest.mark.usefixtures("mock_aiontfy", "mock_update_checker") +async def test_with_official_server(hass: HomeAssistant) -> None: + """Test update entity is not created when using official ntfy server.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="ntfy.sh", + data={ + CONF_URL: DEFAULT_URL, + CONF_USERNAME: None, + CONF_TOKEN: "token", + CONF_VERIFY_SSL: True, + }, + entry_id="123456789", + ) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get("update.ntfy_sh_ntfy_version") + assert state is None diff --git a/tests/components/renault/test_services.py b/tests/components/renault/test_services.py index 1bef2023d5bf2..7ce168623debc 100644 --- a/tests/components/renault/test_services.py +++ b/tests/components/renault/test_services.py @@ -16,10 +16,6 @@ ATTR_TEMPERATURE, ATTR_VEHICLE, ATTR_WHEN, - SERVICE_AC_CANCEL, - SERVICE_AC_SET_SCHEDULES, - SERVICE_AC_START, - SERVICE_CHARGE_SET_SCHEDULES, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -72,7 +68,7 @@ async def test_service_set_ac_cancel( ), ) as mock_action: await hass.services.async_call( - DOMAIN, SERVICE_AC_CANCEL, service_data=data, blocking=True + DOMAIN, "ac_cancel", service_data=data, blocking=True ) assert len(mock_action.mock_calls) == 1 assert mock_action.mock_calls[0][1] == () @@ -100,7 +96,7 @@ async def test_service_set_ac_start_simple( ), ) as mock_action: await hass.services.async_call( - DOMAIN, SERVICE_AC_START, service_data=data, blocking=True + DOMAIN, "ac_start", service_data=data, blocking=True ) assert len(mock_action.mock_calls) == 1 assert mock_action.mock_calls[0][1] == (temperature, None) @@ -130,7 +126,7 @@ async def test_service_set_ac_start_with_date( ), ) as mock_action: await hass.services.async_call( - DOMAIN, SERVICE_AC_START, service_data=data, blocking=True + DOMAIN, "ac_start", service_data=data, blocking=True ) assert len(mock_action.mock_calls) == 1 assert mock_action.mock_calls[0][1] == (temperature, when) @@ -169,7 +165,7 @@ async def test_service_set_charge_schedule( ) as mock_action, ): await hass.services.async_call( - DOMAIN, SERVICE_CHARGE_SET_SCHEDULES, service_data=data, blocking=True + DOMAIN, "charge_set_schedules", service_data=data, blocking=True ) assert len(mock_action.mock_calls) == 1 mock_call_data: list[ChargeSchedule] = mock_action.mock_calls[0][1][0] @@ -221,7 +217,7 @@ async def test_service_set_charge_schedule_multi( ) as mock_action, ): await hass.services.async_call( - DOMAIN, SERVICE_CHARGE_SET_SCHEDULES, service_data=data, blocking=True + DOMAIN, "charge_set_schedules", service_data=data, blocking=True ) assert len(mock_action.mock_calls) == 1 mock_call_data: list[ChargeSchedule] = mock_action.mock_calls[0][1][0] @@ -269,7 +265,7 @@ async def test_service_set_ac_schedule( ) as mock_action, ): await hass.services.async_call( - DOMAIN, SERVICE_AC_SET_SCHEDULES, service_data=data, blocking=True + DOMAIN, "ac_set_schedules", service_data=data, blocking=True ) assert len(mock_action.mock_calls) == 1 mock_call_data: list[ChargeSchedule] = mock_action.mock_calls[0][1][0] @@ -320,7 +316,7 @@ async def test_service_set_ac_schedule_multi( ) as mock_action, ): await hass.services.async_call( - DOMAIN, SERVICE_AC_SET_SCHEDULES, service_data=data, blocking=True + DOMAIN, "ac_set_schedules", service_data=data, blocking=True ) assert len(mock_action.mock_calls) == 1 mock_call_data: list[HvacSchedule] = mock_action.mock_calls[0][1][0] @@ -347,7 +343,7 @@ async def test_service_invalid_device_id( with pytest.raises(ServiceValidationError) as err: await hass.services.async_call( - DOMAIN, SERVICE_AC_CANCEL, service_data=data, blocking=True + DOMAIN, "ac_cancel", service_data=data, blocking=True ) assert err.value.translation_key == "invalid_device_id" assert err.value.translation_placeholders == {"device_id": "some_random_id"} @@ -375,7 +371,7 @@ async def test_service_invalid_device_id2( with pytest.raises(ServiceValidationError) as err: await hass.services.async_call( - DOMAIN, SERVICE_AC_CANCEL, service_data=data, blocking=True + DOMAIN, "ac_cancel", service_data=data, blocking=True ) assert err.value.translation_key == "no_config_entry_for_device" assert err.value.translation_placeholders == {"device_id": "REG-NUMBER"} @@ -400,7 +396,7 @@ async def test_service_exception( pytest.raises(HomeAssistantError, match="Didn't work"), ): await hass.services.async_call( - DOMAIN, SERVICE_AC_CANCEL, service_data=data, blocking=True + DOMAIN, "ac_cancel", service_data=data, blocking=True ) assert len(mock_action.mock_calls) == 1 assert mock_action.mock_calls[0][1] == () diff --git a/tests/components/route_b_smart_meter/conftest.py b/tests/components/route_b_smart_meter/conftest.py index f0a84c252a0c5..cbaad0f838872 100644 --- a/tests/components/route_b_smart_meter/conftest.py +++ b/tests/components/route_b_smart_meter/conftest.py @@ -44,6 +44,10 @@ def mock_momonga(exception=None) -> Generator[Mock]: } client.get_instantaneous_power.return_value = 3 client.get_measured_cumulative_energy.return_value = 4 + client.get_serial_number.return_value = "TEST_SERIAL" + client.get_manufacturer_code.return_value = b"\x00\x00\x16" + client.get_standard_version.return_value = "F.0" + client.internal_xmit_interval = 0 yield mock_momonga diff --git a/tests/components/smarla/conftest.py b/tests/components/smarla/conftest.py index 49e9723a52b63..cd626174ce247 100644 --- a/tests/components/smarla/conftest.py +++ b/tests/components/smarla/conftest.py @@ -3,10 +3,11 @@ from __future__ import annotations from collections.abc import Generator -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from pysmarlaapi import AuthToken from pysmarlaapi.federwiege.services.classes import Property, Service +from pysmarlaapi.federwiege.services.types import UpdateStatus import pytest from homeassistant.components.smarla.const import DOMAIN @@ -58,6 +59,62 @@ def mocked_connection(url, token_b64: str): yield connection +def _mock_babywiege_service() -> MagicMock: + mock_babywiege_service = MagicMock(spec=Service) + mock_babywiege_service.props = { + "swing_active": MagicMock(spec=Property), + "smart_mode": MagicMock(spec=Property), + "intensity": MagicMock(spec=Property), + } + + mock_babywiege_service.props["swing_active"].get.return_value = False + mock_babywiege_service.props["smart_mode"].get.return_value = False + mock_babywiege_service.props["intensity"].get.return_value = 1 + + return mock_babywiege_service + + +def _mock_analyser_service() -> MagicMock: + mock_analyser_service = MagicMock(spec=Service) + mock_analyser_service.props = { + "oscillation": MagicMock(spec=Property), + "activity": MagicMock(spec=Property), + "swing_count": MagicMock(spec=Property), + } + + mock_analyser_service.props["oscillation"].get.return_value = [0, 0] + mock_analyser_service.props["activity"].get.return_value = 0 + mock_analyser_service.props["swing_count"].get.return_value = 0 + + return mock_analyser_service + + +def _mock_info_service() -> MagicMock: + mock_info_service = MagicMock(spec=Service) + mock_info_service.props = { + "version": MagicMock(spec=Property), + } + + mock_info_service.props["version"].get.return_value = "1.0.0" + + return mock_info_service + + +def _mock_system_service() -> MagicMock: + mock_system_service = MagicMock(spec=Service) + mock_system_service.props = { + "firmware_update": MagicMock(spec=Property), + "firmware_update_status": MagicMock(spec=Property), + } + + mock_system_service.props["firmware_update"].get.return_value = 0 + mock_system_service.props[ + "firmware_update_status" + ].get.return_value = UpdateStatus.IDLE + + return mock_system_service + + @pytest.fixture def mock_federwiege_cls(mock_connection: MagicMock) -> Generator[MagicMock]: """Mock the Federwiege class.""" @@ -68,31 +125,13 @@ def mock_federwiege_cls(mock_connection: MagicMock) -> Generator[MagicMock]: mock_federwiege.serial_number = MOCK_ACCESS_TOKEN_JSON["serialNumber"] mock_federwiege.available = True - mock_babywiege_service = MagicMock(spec=Service) - mock_babywiege_service.props = { - "swing_active": MagicMock(spec=Property), - "smart_mode": MagicMock(spec=Property), - "intensity": MagicMock(spec=Property), - } - - mock_babywiege_service.props["swing_active"].get.return_value = False - mock_babywiege_service.props["smart_mode"].get.return_value = False - mock_babywiege_service.props["intensity"].get.return_value = 1 - - mock_analyser_service = MagicMock(spec=Service) - mock_analyser_service.props = { - "oscillation": MagicMock(spec=Property), - "activity": MagicMock(spec=Property), - "swing_count": MagicMock(spec=Property), - } - - mock_analyser_service.props["oscillation"].get.return_value = [0, 0] - mock_analyser_service.props["activity"].get.return_value = 0 - mock_analyser_service.props["swing_count"].get.return_value = 0 + mock_federwiege.check_firmware_update = AsyncMock(return_value=("1.0.0", "")) mock_federwiege.services = { - "babywiege": mock_babywiege_service, - "analyser": mock_analyser_service, + "babywiege": _mock_babywiege_service(), + "analyser": _mock_analyser_service(), + "info": _mock_info_service(), + "system": _mock_system_service(), } mock_federwiege.get_property = MagicMock( diff --git a/tests/components/smarla/snapshots/test_update.ambr b/tests/components/smarla/snapshots/test_update.ambr new file mode 100644 index 0000000000000..33dc5ad1835a4 --- /dev/null +++ b/tests/components/smarla/snapshots/test_update.ambr @@ -0,0 +1,63 @@ +# serializer version: 1 +# name: test_update[update.smarla_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.smarla_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Firmware', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'smarla', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'ABCD-update', + 'unit_of_measurement': None, + }) +# --- +# name: test_update[update.smarla_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smarla/icon.png', + 'friendly_name': 'Smarla Firmware', + 'in_progress': False, + 'installed_version': '1.0.0', + 'latest_version': '1.0.0', + 'release_summary': '', + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.smarla_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/smarla/test_update.py b/tests/components/smarla/test_update.py new file mode 100644 index 0000000000000..bb8af22c59099 --- /dev/null +++ b/tests/components/smarla/test_update.py @@ -0,0 +1,140 @@ +"""Test update platform for Swing2Sleep Smarla integration.""" + +from unittest.mock import MagicMock, patch + +from pysmarlaapi.federwiege.services.types import UpdateStatus +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.update import ( + ATTR_IN_PROGRESS, + ATTR_INSTALLED_VERSION, + ATTR_LATEST_VERSION, + DOMAIN as UPDATE_DOMAIN, + SERVICE_INSTALL, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_component import async_update_entity + +from . import setup_integration, update_property_listeners + +from tests.common import MockConfigEntry, snapshot_platform + +UPDATE_ENTITY_ID = "update.smarla_firmware" + + +@pytest.mark.usefixtures("mock_federwiege") +async def test_update( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the smarla update platform.""" + with patch("homeassistant.components.smarla.PLATFORMS", [Platform.UPDATE]): + assert await setup_integration(hass, mock_config_entry) + + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) + + +async def test_update_available( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_federwiege: MagicMock, +) -> None: + """Test smarla update initial state and behavior when an update gets available.""" + assert await setup_integration(hass, mock_config_entry) + + state = hass.states.get(UPDATE_ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + assert state.attributes[ATTR_INSTALLED_VERSION] == "1.0.0" + assert state.attributes[ATTR_LATEST_VERSION] == "1.0.0" + + mock_federwiege.check_firmware_update.return_value = ("1.1.0", "") + await async_update_entity(hass, UPDATE_ENTITY_ID) + await hass.async_block_till_done() + + state = hass.states.get(UPDATE_ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + assert state.attributes[ATTR_LATEST_VERSION] == "1.1.0" + + +async def test_update_install( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_federwiege: MagicMock, +) -> None: + """Test the smarla update install action.""" + mock_federwiege.check_firmware_update.return_value = ("1.1.0", "") + assert await setup_integration(hass, mock_config_entry) + + mock_update_property = mock_federwiege.get_property("system", "firmware_update") + + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: UPDATE_ENTITY_ID}, + blocking=True, + ) + + mock_update_property.set.assert_called_once_with(1) + + +@pytest.mark.parametrize("status", [UpdateStatus.DOWNLOADING, UpdateStatus.INSTALLING]) +async def test_update_in_progress( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_federwiege: MagicMock, + status: UpdateStatus, +) -> None: + """Test the smarla update progress.""" + assert await setup_integration(hass, mock_config_entry) + + mock_update_status_property = mock_federwiege.get_property( + "system", "firmware_update_status" + ) + + state = hass.states.get(UPDATE_ENTITY_ID) + assert state is not None + assert state.attributes[ATTR_IN_PROGRESS] is False + + mock_update_status_property.get.return_value = status + await update_property_listeners(mock_update_status_property) + await hass.async_block_till_done() + + state = hass.states.get(UPDATE_ENTITY_ID) + assert state is not None + assert state.attributes[ATTR_IN_PROGRESS] is True + + +async def test_update_unknown( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_federwiege: MagicMock, +) -> None: + """Test smarla update unknown behavior.""" + assert await setup_integration(hass, mock_config_entry) + + state = hass.states.get(UPDATE_ENTITY_ID) + assert state is not None + assert state.state != STATE_UNKNOWN + + mock_federwiege.check_firmware_update.return_value = None + await async_update_entity(hass, UPDATE_ENTITY_ID) + await hass.async_block_till_done() + + state = hass.states.get(UPDATE_ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index fbd0b41d6a0e2..c8d6522743dd3 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -5758,7 +5758,7 @@ 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': 'A', }), }), 'original_device_class': , @@ -5770,7 +5770,7 @@ 'supported_features': 0, 'translation_key': 'phase_a_current', 'unique_id': 'tuya.qi94v9dmdx4fkpncqldphase_aelectriccurrent', - 'unit_of_measurement': , + 'unit_of_measurement': 'A', }) # --- # name: test_platform_setup_and_discovery[sensor.duan_lu_qi_ha_phase_a_current-state] @@ -5779,7 +5779,7 @@ 'device_class': 'current', 'friendly_name': '断路器HA Phase A current', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'A', }), 'context': , 'entity_id': 'sensor.duan_lu_qi_ha_phase_a_current', @@ -5818,7 +5818,7 @@ 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': 'kW', }), }), 'original_device_class': , @@ -5830,7 +5830,7 @@ 'supported_features': 0, 'translation_key': 'phase_a_power', 'unique_id': 'tuya.qi94v9dmdx4fkpncqldphase_apower', - 'unit_of_measurement': , + 'unit_of_measurement': 'kW', }) # --- # name: test_platform_setup_and_discovery[sensor.duan_lu_qi_ha_phase_a_power-state] @@ -5839,7 +5839,7 @@ 'device_class': 'power', 'friendly_name': '断路器HA Phase A power', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'kW', }), 'context': , 'entity_id': 'sensor.duan_lu_qi_ha_phase_a_power', @@ -5887,7 +5887,7 @@ 'supported_features': 0, 'translation_key': 'phase_a_voltage', 'unique_id': 'tuya.qi94v9dmdx4fkpncqldphase_avoltage', - 'unit_of_measurement': , + 'unit_of_measurement': 'V', }) # --- # name: test_platform_setup_and_discovery[sensor.duan_lu_qi_ha_phase_a_voltage-state] @@ -5896,7 +5896,7 @@ 'device_class': 'voltage', 'friendly_name': '断路器HA Phase A voltage', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'V', }), 'context': , 'entity_id': 'sensor.duan_lu_qi_ha_phase_a_voltage', @@ -6279,7 +6279,7 @@ 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': 'A', }), }), 'original_device_class': , @@ -6291,7 +6291,7 @@ 'supported_features': 0, 'translation_key': 'phase_a_current', 'unique_id': 'tuya.vcrfgwvbuybgnj3zqldphase_aelectriccurrent', - 'unit_of_measurement': , + 'unit_of_measurement': 'A', }) # --- # name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_a_current-state] @@ -6300,7 +6300,7 @@ 'device_class': 'current', 'friendly_name': 'Edesanya Energy Phase A current', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'A', }), 'context': , 'entity_id': 'sensor.edesanya_energy_phase_a_current', @@ -6339,7 +6339,7 @@ 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': 'kW', }), }), 'original_device_class': , @@ -6351,7 +6351,7 @@ 'supported_features': 0, 'translation_key': 'phase_a_power', 'unique_id': 'tuya.vcrfgwvbuybgnj3zqldphase_apower', - 'unit_of_measurement': , + 'unit_of_measurement': 'kW', }) # --- # name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_a_power-state] @@ -6360,7 +6360,7 @@ 'device_class': 'power', 'friendly_name': 'Edesanya Energy Phase A power', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'kW', }), 'context': , 'entity_id': 'sensor.edesanya_energy_phase_a_power', @@ -6408,7 +6408,7 @@ 'supported_features': 0, 'translation_key': 'phase_a_voltage', 'unique_id': 'tuya.vcrfgwvbuybgnj3zqldphase_avoltage', - 'unit_of_measurement': , + 'unit_of_measurement': 'V', }) # --- # name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_a_voltage-state] @@ -6417,7 +6417,7 @@ 'device_class': 'voltage', 'friendly_name': 'Edesanya Energy Phase A voltage', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'V', }), 'context': , 'entity_id': 'sensor.edesanya_energy_phase_a_voltage', @@ -6456,7 +6456,7 @@ 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': 'A', }), }), 'original_device_class': , @@ -6468,7 +6468,7 @@ 'supported_features': 0, 'translation_key': 'phase_b_current', 'unique_id': 'tuya.vcrfgwvbuybgnj3zqldphase_belectriccurrent', - 'unit_of_measurement': , + 'unit_of_measurement': 'A', }) # --- # name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_b_current-state] @@ -6477,7 +6477,7 @@ 'device_class': 'current', 'friendly_name': 'Edesanya Energy Phase B current', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'A', }), 'context': , 'entity_id': 'sensor.edesanya_energy_phase_b_current', @@ -6516,7 +6516,7 @@ 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': 'kW', }), }), 'original_device_class': , @@ -6528,7 +6528,7 @@ 'supported_features': 0, 'translation_key': 'phase_b_power', 'unique_id': 'tuya.vcrfgwvbuybgnj3zqldphase_bpower', - 'unit_of_measurement': , + 'unit_of_measurement': 'kW', }) # --- # name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_b_power-state] @@ -6537,7 +6537,7 @@ 'device_class': 'power', 'friendly_name': 'Edesanya Energy Phase B power', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'kW', }), 'context': , 'entity_id': 'sensor.edesanya_energy_phase_b_power', @@ -6585,7 +6585,7 @@ 'supported_features': 0, 'translation_key': 'phase_b_voltage', 'unique_id': 'tuya.vcrfgwvbuybgnj3zqldphase_bvoltage', - 'unit_of_measurement': , + 'unit_of_measurement': 'V', }) # --- # name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_b_voltage-state] @@ -6594,7 +6594,7 @@ 'device_class': 'voltage', 'friendly_name': 'Edesanya Energy Phase B voltage', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'V', }), 'context': , 'entity_id': 'sensor.edesanya_energy_phase_b_voltage', @@ -6633,7 +6633,7 @@ 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': 'A', }), }), 'original_device_class': , @@ -6645,7 +6645,7 @@ 'supported_features': 0, 'translation_key': 'phase_c_current', 'unique_id': 'tuya.vcrfgwvbuybgnj3zqldphase_celectriccurrent', - 'unit_of_measurement': , + 'unit_of_measurement': 'A', }) # --- # name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_c_current-state] @@ -6654,7 +6654,7 @@ 'device_class': 'current', 'friendly_name': 'Edesanya Energy Phase C current', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'A', }), 'context': , 'entity_id': 'sensor.edesanya_energy_phase_c_current', @@ -6693,7 +6693,7 @@ 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': 'kW', }), }), 'original_device_class': , @@ -6705,7 +6705,7 @@ 'supported_features': 0, 'translation_key': 'phase_c_power', 'unique_id': 'tuya.vcrfgwvbuybgnj3zqldphase_cpower', - 'unit_of_measurement': , + 'unit_of_measurement': 'kW', }) # --- # name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_c_power-state] @@ -6714,7 +6714,7 @@ 'device_class': 'power', 'friendly_name': 'Edesanya Energy Phase C power', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'kW', }), 'context': , 'entity_id': 'sensor.edesanya_energy_phase_c_power', @@ -6762,7 +6762,7 @@ 'supported_features': 0, 'translation_key': 'phase_c_voltage', 'unique_id': 'tuya.vcrfgwvbuybgnj3zqldphase_cvoltage', - 'unit_of_measurement': , + 'unit_of_measurement': 'V', }) # --- # name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_c_voltage-state] @@ -6771,7 +6771,7 @@ 'device_class': 'voltage', 'friendly_name': 'Edesanya Energy Phase C voltage', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'V', }), 'context': , 'entity_id': 'sensor.edesanya_energy_phase_c_voltage', @@ -12497,7 +12497,7 @@ 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': 'A', }), }), 'original_device_class': , @@ -12509,7 +12509,7 @@ 'supported_features': 0, 'translation_key': 'phase_a_current', 'unique_id': 'tuya.6pd3bkidqldphase_aelectriccurrent', - 'unit_of_measurement': , + 'unit_of_measurement': 'A', }) # --- # name: test_platform_setup_and_discovery[sensor.medidor_de_energia_phase_a_current-state] @@ -12518,7 +12518,7 @@ 'device_class': 'current', 'friendly_name': 'Medidor de Energia Phase A current', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'A', }), 'context': , 'entity_id': 'sensor.medidor_de_energia_phase_a_current', @@ -12557,7 +12557,7 @@ 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': 'kW', }), }), 'original_device_class': , @@ -12569,7 +12569,7 @@ 'supported_features': 0, 'translation_key': 'phase_a_power', 'unique_id': 'tuya.6pd3bkidqldphase_apower', - 'unit_of_measurement': , + 'unit_of_measurement': 'kW', }) # --- # name: test_platform_setup_and_discovery[sensor.medidor_de_energia_phase_a_power-state] @@ -12578,7 +12578,7 @@ 'device_class': 'power', 'friendly_name': 'Medidor de Energia Phase A power', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'kW', }), 'context': , 'entity_id': 'sensor.medidor_de_energia_phase_a_power', @@ -12626,7 +12626,7 @@ 'supported_features': 0, 'translation_key': 'phase_a_voltage', 'unique_id': 'tuya.6pd3bkidqldphase_avoltage', - 'unit_of_measurement': , + 'unit_of_measurement': 'V', }) # --- # name: test_platform_setup_and_discovery[sensor.medidor_de_energia_phase_a_voltage-state] @@ -12635,7 +12635,7 @@ 'device_class': 'voltage', 'friendly_name': 'Medidor de Energia Phase A voltage', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'V', }), 'context': , 'entity_id': 'sensor.medidor_de_energia_phase_a_voltage', @@ -12674,7 +12674,7 @@ 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': 'A', }), }), 'original_device_class': , @@ -12686,7 +12686,7 @@ 'supported_features': 0, 'translation_key': 'phase_b_current', 'unique_id': 'tuya.6pd3bkidqldphase_belectriccurrent', - 'unit_of_measurement': , + 'unit_of_measurement': 'A', }) # --- # name: test_platform_setup_and_discovery[sensor.medidor_de_energia_phase_b_current-state] @@ -12695,7 +12695,7 @@ 'device_class': 'current', 'friendly_name': 'Medidor de Energia Phase B current', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'A', }), 'context': , 'entity_id': 'sensor.medidor_de_energia_phase_b_current', @@ -12734,7 +12734,7 @@ 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': 'kW', }), }), 'original_device_class': , @@ -12746,7 +12746,7 @@ 'supported_features': 0, 'translation_key': 'phase_b_power', 'unique_id': 'tuya.6pd3bkidqldphase_bpower', - 'unit_of_measurement': , + 'unit_of_measurement': 'kW', }) # --- # name: test_platform_setup_and_discovery[sensor.medidor_de_energia_phase_b_power-state] @@ -12755,7 +12755,7 @@ 'device_class': 'power', 'friendly_name': 'Medidor de Energia Phase B power', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'kW', }), 'context': , 'entity_id': 'sensor.medidor_de_energia_phase_b_power', @@ -12803,7 +12803,7 @@ 'supported_features': 0, 'translation_key': 'phase_b_voltage', 'unique_id': 'tuya.6pd3bkidqldphase_bvoltage', - 'unit_of_measurement': , + 'unit_of_measurement': 'V', }) # --- # name: test_platform_setup_and_discovery[sensor.medidor_de_energia_phase_b_voltage-state] @@ -12812,7 +12812,7 @@ 'device_class': 'voltage', 'friendly_name': 'Medidor de Energia Phase B voltage', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'V', }), 'context': , 'entity_id': 'sensor.medidor_de_energia_phase_b_voltage', @@ -12851,7 +12851,7 @@ 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': 'A', }), }), 'original_device_class': , @@ -12863,7 +12863,7 @@ 'supported_features': 0, 'translation_key': 'phase_c_current', 'unique_id': 'tuya.6pd3bkidqldphase_celectriccurrent', - 'unit_of_measurement': , + 'unit_of_measurement': 'A', }) # --- # name: test_platform_setup_and_discovery[sensor.medidor_de_energia_phase_c_current-state] @@ -12872,7 +12872,7 @@ 'device_class': 'current', 'friendly_name': 'Medidor de Energia Phase C current', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'A', }), 'context': , 'entity_id': 'sensor.medidor_de_energia_phase_c_current', @@ -12911,7 +12911,7 @@ 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': 'kW', }), }), 'original_device_class': , @@ -12923,7 +12923,7 @@ 'supported_features': 0, 'translation_key': 'phase_c_power', 'unique_id': 'tuya.6pd3bkidqldphase_cpower', - 'unit_of_measurement': , + 'unit_of_measurement': 'kW', }) # --- # name: test_platform_setup_and_discovery[sensor.medidor_de_energia_phase_c_power-state] @@ -12932,7 +12932,7 @@ 'device_class': 'power', 'friendly_name': 'Medidor de Energia Phase C power', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'kW', }), 'context': , 'entity_id': 'sensor.medidor_de_energia_phase_c_power', @@ -12980,7 +12980,7 @@ 'supported_features': 0, 'translation_key': 'phase_c_voltage', 'unique_id': 'tuya.6pd3bkidqldphase_cvoltage', - 'unit_of_measurement': , + 'unit_of_measurement': 'V', }) # --- # name: test_platform_setup_and_discovery[sensor.medidor_de_energia_phase_c_voltage-state] @@ -12989,7 +12989,7 @@ 'device_class': 'voltage', 'friendly_name': 'Medidor de Energia Phase C voltage', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'V', }), 'context': , 'entity_id': 'sensor.medidor_de_energia_phase_c_voltage', @@ -13199,7 +13199,7 @@ 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': 'A', }), }), 'original_device_class': , @@ -13211,7 +13211,7 @@ 'supported_features': 0, 'translation_key': 'phase_a_current', 'unique_id': 'tuya.nnqlg0rxryraf8ezbdnzphase_aelectriccurrent', - 'unit_of_measurement': , + 'unit_of_measurement': 'A', }) # --- # name: test_platform_setup_and_discovery[sensor.meter_phase_a_current-state] @@ -13220,7 +13220,7 @@ 'device_class': 'current', 'friendly_name': 'Meter Phase A current', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'A', }), 'context': , 'entity_id': 'sensor.meter_phase_a_current', @@ -13259,7 +13259,7 @@ 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': 'kW', }), }), 'original_device_class': , @@ -13271,7 +13271,7 @@ 'supported_features': 0, 'translation_key': 'phase_a_power', 'unique_id': 'tuya.nnqlg0rxryraf8ezbdnzphase_apower', - 'unit_of_measurement': , + 'unit_of_measurement': 'kW', }) # --- # name: test_platform_setup_and_discovery[sensor.meter_phase_a_power-state] @@ -13280,7 +13280,7 @@ 'device_class': 'power', 'friendly_name': 'Meter Phase A power', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'kW', }), 'context': , 'entity_id': 'sensor.meter_phase_a_power', @@ -13328,7 +13328,7 @@ 'supported_features': 0, 'translation_key': 'phase_a_voltage', 'unique_id': 'tuya.nnqlg0rxryraf8ezbdnzphase_avoltage', - 'unit_of_measurement': , + 'unit_of_measurement': 'V', }) # --- # name: test_platform_setup_and_discovery[sensor.meter_phase_a_voltage-state] @@ -13337,7 +13337,7 @@ 'device_class': 'voltage', 'friendly_name': 'Meter Phase A voltage', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'V', }), 'context': , 'entity_id': 'sensor.meter_phase_a_voltage', @@ -13490,7 +13490,7 @@ 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': 'A', }), }), 'original_device_class': , @@ -13502,7 +13502,7 @@ 'supported_features': 0, 'translation_key': 'phase_a_current', 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_aelectriccurrent', - 'unit_of_measurement': , + 'unit_of_measurement': 'A', }) # --- # name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_a_current-state] @@ -13511,7 +13511,7 @@ 'device_class': 'current', 'friendly_name': 'Metering_3PN_WiFi_stable Phase A current', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'A', }), 'context': , 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_a_current', @@ -13550,7 +13550,7 @@ 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': 'kW', }), }), 'original_device_class': , @@ -13562,7 +13562,7 @@ 'supported_features': 0, 'translation_key': 'phase_a_power', 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_apower', - 'unit_of_measurement': , + 'unit_of_measurement': 'kW', }) # --- # name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_a_power-state] @@ -13571,7 +13571,7 @@ 'device_class': 'power', 'friendly_name': 'Metering_3PN_WiFi_stable Phase A power', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'kW', }), 'context': , 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_a_power', @@ -13619,7 +13619,7 @@ 'supported_features': 0, 'translation_key': 'phase_a_voltage', 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_avoltage', - 'unit_of_measurement': , + 'unit_of_measurement': 'V', }) # --- # name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_a_voltage-state] @@ -13628,7 +13628,7 @@ 'device_class': 'voltage', 'friendly_name': 'Metering_3PN_WiFi_stable Phase A voltage', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'V', }), 'context': , 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_a_voltage', @@ -13667,7 +13667,7 @@ 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': 'A', }), }), 'original_device_class': , @@ -13679,7 +13679,7 @@ 'supported_features': 0, 'translation_key': 'phase_b_current', 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_belectriccurrent', - 'unit_of_measurement': , + 'unit_of_measurement': 'A', }) # --- # name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_b_current-state] @@ -13688,7 +13688,7 @@ 'device_class': 'current', 'friendly_name': 'Metering_3PN_WiFi_stable Phase B current', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'A', }), 'context': , 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_b_current', @@ -13727,7 +13727,7 @@ 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': 'kW', }), }), 'original_device_class': , @@ -13739,7 +13739,7 @@ 'supported_features': 0, 'translation_key': 'phase_b_power', 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_bpower', - 'unit_of_measurement': , + 'unit_of_measurement': 'kW', }) # --- # name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_b_power-state] @@ -13748,7 +13748,7 @@ 'device_class': 'power', 'friendly_name': 'Metering_3PN_WiFi_stable Phase B power', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'kW', }), 'context': , 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_b_power', @@ -13796,7 +13796,7 @@ 'supported_features': 0, 'translation_key': 'phase_b_voltage', 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_bvoltage', - 'unit_of_measurement': , + 'unit_of_measurement': 'V', }) # --- # name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_b_voltage-state] @@ -13805,7 +13805,7 @@ 'device_class': 'voltage', 'friendly_name': 'Metering_3PN_WiFi_stable Phase B voltage', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'V', }), 'context': , 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_b_voltage', @@ -13844,7 +13844,7 @@ 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': 'A', }), }), 'original_device_class': , @@ -13856,7 +13856,7 @@ 'supported_features': 0, 'translation_key': 'phase_c_current', 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_celectriccurrent', - 'unit_of_measurement': , + 'unit_of_measurement': 'A', }) # --- # name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_c_current-state] @@ -13865,7 +13865,7 @@ 'device_class': 'current', 'friendly_name': 'Metering_3PN_WiFi_stable Phase C current', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'A', }), 'context': , 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_c_current', @@ -13904,7 +13904,7 @@ 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': 'kW', }), }), 'original_device_class': , @@ -13916,7 +13916,7 @@ 'supported_features': 0, 'translation_key': 'phase_c_power', 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_cpower', - 'unit_of_measurement': , + 'unit_of_measurement': 'kW', }) # --- # name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_c_power-state] @@ -13925,7 +13925,7 @@ 'device_class': 'power', 'friendly_name': 'Metering_3PN_WiFi_stable Phase C power', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'kW', }), 'context': , 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_c_power', @@ -13973,7 +13973,7 @@ 'supported_features': 0, 'translation_key': 'phase_c_voltage', 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_cvoltage', - 'unit_of_measurement': , + 'unit_of_measurement': 'V', }) # --- # name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_c_voltage-state] @@ -13982,7 +13982,7 @@ 'device_class': 'voltage', 'friendly_name': 'Metering_3PN_WiFi_stable Phase C voltage', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'V', }), 'context': , 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_c_voltage', @@ -15292,7 +15292,7 @@ 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': 'A', }), }), 'original_device_class': , @@ -15304,7 +15304,7 @@ 'supported_features': 0, 'translation_key': 'phase_a_current', 'unique_id': 'tuya.bcyciyhhu1g2gk9rqldphase_aelectriccurrent', - 'unit_of_measurement': , + 'unit_of_measurement': 'A', }) # --- # name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_a_current-state] @@ -15313,7 +15313,7 @@ 'device_class': 'current', 'friendly_name': 'P1 Energia Elettrica Phase A current', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'A', }), 'context': , 'entity_id': 'sensor.p1_energia_elettrica_phase_a_current', @@ -15352,7 +15352,7 @@ 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': 'kW', }), }), 'original_device_class': , @@ -15364,7 +15364,7 @@ 'supported_features': 0, 'translation_key': 'phase_a_power', 'unique_id': 'tuya.bcyciyhhu1g2gk9rqldphase_apower', - 'unit_of_measurement': , + 'unit_of_measurement': 'kW', }) # --- # name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_a_power-state] @@ -15373,7 +15373,7 @@ 'device_class': 'power', 'friendly_name': 'P1 Energia Elettrica Phase A power', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'kW', }), 'context': , 'entity_id': 'sensor.p1_energia_elettrica_phase_a_power', @@ -15421,7 +15421,7 @@ 'supported_features': 0, 'translation_key': 'phase_a_voltage', 'unique_id': 'tuya.bcyciyhhu1g2gk9rqldphase_avoltage', - 'unit_of_measurement': , + 'unit_of_measurement': 'V', }) # --- # name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_a_voltage-state] @@ -15430,7 +15430,7 @@ 'device_class': 'voltage', 'friendly_name': 'P1 Energia Elettrica Phase A voltage', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'V', }), 'context': , 'entity_id': 'sensor.p1_energia_elettrica_phase_a_voltage', @@ -15469,7 +15469,7 @@ 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': 'A', }), }), 'original_device_class': , @@ -15481,7 +15481,7 @@ 'supported_features': 0, 'translation_key': 'phase_b_current', 'unique_id': 'tuya.bcyciyhhu1g2gk9rqldphase_belectriccurrent', - 'unit_of_measurement': , + 'unit_of_measurement': 'A', }) # --- # name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_b_current-state] @@ -15490,7 +15490,7 @@ 'device_class': 'current', 'friendly_name': 'P1 Energia Elettrica Phase B current', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'A', }), 'context': , 'entity_id': 'sensor.p1_energia_elettrica_phase_b_current', @@ -15529,7 +15529,7 @@ 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': 'kW', }), }), 'original_device_class': , @@ -15541,7 +15541,7 @@ 'supported_features': 0, 'translation_key': 'phase_b_power', 'unique_id': 'tuya.bcyciyhhu1g2gk9rqldphase_bpower', - 'unit_of_measurement': , + 'unit_of_measurement': 'kW', }) # --- # name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_b_power-state] @@ -15550,7 +15550,7 @@ 'device_class': 'power', 'friendly_name': 'P1 Energia Elettrica Phase B power', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'kW', }), 'context': , 'entity_id': 'sensor.p1_energia_elettrica_phase_b_power', @@ -15598,7 +15598,7 @@ 'supported_features': 0, 'translation_key': 'phase_b_voltage', 'unique_id': 'tuya.bcyciyhhu1g2gk9rqldphase_bvoltage', - 'unit_of_measurement': , + 'unit_of_measurement': 'V', }) # --- # name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_b_voltage-state] @@ -15607,7 +15607,7 @@ 'device_class': 'voltage', 'friendly_name': 'P1 Energia Elettrica Phase B voltage', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'V', }), 'context': , 'entity_id': 'sensor.p1_energia_elettrica_phase_b_voltage', @@ -15646,7 +15646,7 @@ 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': 'A', }), }), 'original_device_class': , @@ -15658,7 +15658,7 @@ 'supported_features': 0, 'translation_key': 'phase_c_current', 'unique_id': 'tuya.bcyciyhhu1g2gk9rqldphase_celectriccurrent', - 'unit_of_measurement': , + 'unit_of_measurement': 'A', }) # --- # name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_c_current-state] @@ -15667,7 +15667,7 @@ 'device_class': 'current', 'friendly_name': 'P1 Energia Elettrica Phase C current', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'A', }), 'context': , 'entity_id': 'sensor.p1_energia_elettrica_phase_c_current', @@ -15706,7 +15706,7 @@ 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': 'kW', }), }), 'original_device_class': , @@ -15718,7 +15718,7 @@ 'supported_features': 0, 'translation_key': 'phase_c_power', 'unique_id': 'tuya.bcyciyhhu1g2gk9rqldphase_cpower', - 'unit_of_measurement': , + 'unit_of_measurement': 'kW', }) # --- # name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_c_power-state] @@ -15727,7 +15727,7 @@ 'device_class': 'power', 'friendly_name': 'P1 Energia Elettrica Phase C power', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'kW', }), 'context': , 'entity_id': 'sensor.p1_energia_elettrica_phase_c_power', @@ -15775,7 +15775,7 @@ 'supported_features': 0, 'translation_key': 'phase_c_voltage', 'unique_id': 'tuya.bcyciyhhu1g2gk9rqldphase_cvoltage', - 'unit_of_measurement': , + 'unit_of_measurement': 'V', }) # --- # name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_c_voltage-state] @@ -15784,7 +15784,7 @@ 'device_class': 'voltage', 'friendly_name': 'P1 Energia Elettrica Phase C voltage', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'V', }), 'context': , 'entity_id': 'sensor.p1_energia_elettrica_phase_c_voltage', @@ -24209,7 +24209,7 @@ 'supported_features': 0, 'translation_key': 'phase_a_current', 'unique_id': 'tuya.9oh1h1uyalfykgg4bdnzphase_aelectriccurrent', - 'unit_of_measurement': , + 'unit_of_measurement': 'A', }) # --- # name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_phase_a_current-state] @@ -24218,7 +24218,7 @@ 'device_class': 'current', 'friendly_name': 'XOCA-DAC212XC V2-S1 Phase A current', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'A', }), 'context': , 'entity_id': 'sensor.xoca_dac212xc_v2_s1_phase_a_current', @@ -24266,7 +24266,7 @@ 'supported_features': 0, 'translation_key': 'phase_a_power', 'unique_id': 'tuya.9oh1h1uyalfykgg4bdnzphase_apower', - 'unit_of_measurement': , + 'unit_of_measurement': 'kW', }) # --- # name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_phase_a_power-state] @@ -24275,7 +24275,7 @@ 'device_class': 'power', 'friendly_name': 'XOCA-DAC212XC V2-S1 Phase A power', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'kW', }), 'context': , 'entity_id': 'sensor.xoca_dac212xc_v2_s1_phase_a_power', @@ -24323,7 +24323,7 @@ 'supported_features': 0, 'translation_key': 'phase_a_voltage', 'unique_id': 'tuya.9oh1h1uyalfykgg4bdnzphase_avoltage', - 'unit_of_measurement': , + 'unit_of_measurement': 'V', }) # --- # name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_phase_a_voltage-state] @@ -24332,7 +24332,7 @@ 'device_class': 'voltage', 'friendly_name': 'XOCA-DAC212XC V2-S1 Phase A voltage', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'V', }), 'context': , 'entity_id': 'sensor.xoca_dac212xc_v2_s1_phase_a_voltage', diff --git a/tests/components/uptime_kuma/snapshots/test_update.ambr b/tests/components/uptime_kuma/snapshots/test_update.ambr index 1080be61ab90d..dc6bbb2ca4d8c 100644 --- a/tests/components/uptime_kuma/snapshots/test_update.ambr +++ b/tests/components/uptime_kuma/snapshots/test_update.ambr @@ -11,7 +11,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'update', - 'entity_category': , + 'entity_category': , 'entity_id': 'update.uptime_example_org_uptime_kuma_version', 'has_entity_name': True, 'hidden_by': None, diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index 549802d6e7957..7da53a6621368 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -430,10 +430,10 @@ async def test_last_seen_segments( @pytest.mark.usefixtures("config_flow_fixture") -async def test_last_seen_segments_and_issue_creation( +async def test_segments_changed_issue( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: - """Test last_seen_segments property and segments issue creation.""" + """Test segments changed issue.""" mock_vacuum = MockVacuumWithCleanArea(name="Testing", entity_id="vacuum.testing") config_entry = MockConfigEntry(domain="test") @@ -452,6 +452,17 @@ async def test_last_seen_segments_and_issue_creation( await hass.async_block_till_done() entity_entry = entity_registry.async_get(mock_vacuum.entity_id) + + entity_registry.async_update_entity_options( + mock_vacuum.entity_id, + DOMAIN, + { + "area_mapping": {"area_1": ["seg_1"]}, + "last_seen_segments": [asdict(segment) for segment in mock_vacuum.segments], + }, + ) + await hass.async_block_till_done() + mock_vacuum.async_create_segments_issue() issue_id = f"segments_changed_{entity_entry.id}" @@ -460,6 +471,95 @@ async def test_last_seen_segments_and_issue_creation( assert issue.severity == ir.IssueSeverity.WARNING assert issue.translation_key == "segments_changed" + entity_registry.async_update_entity_options( + mock_vacuum.entity_id, + DOMAIN, + { + "area_mapping": {"area_1": ["seg_1"], "area_2": ["seg_new"]}, + "last_seen_segments": [ + {"id": "seg_1", "name": "Kitchen"}, + {"id": "seg_new", "name": "New Room"}, + ], + }, + ) + await hass.async_block_till_done() + + assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None + + +@pytest.mark.usefixtures("config_flow_fixture") +@pytest.mark.parametrize("area_mapping", [{"area_1": ["seg_1"]}, {}]) +async def test_segments_mapping_not_configured_issue( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + area_mapping: dict[str, list[str]], +) -> None: + """Test segments_mapping_not_configured issue.""" + mock_vacuum = MockVacuumWithCleanArea(name="Testing", entity_id="vacuum.testing") + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + async_unload_entry=help_async_unload_entry, + ), + ) + setup_test_component_platform(hass, DOMAIN, [mock_vacuum], from_config_entry=True) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entity_entry = entity_registry.async_get(mock_vacuum.entity_id) + + issue_id = f"segments_mapping_not_configured_{entity_entry.id}" + issue = ir.async_get(hass).async_get_issue(DOMAIN, issue_id) + assert issue is not None + assert issue.severity == ir.IssueSeverity.WARNING + assert issue.translation_key == "segments_mapping_not_configured" + + entity_registry.async_update_entity_options( + mock_vacuum.entity_id, + DOMAIN, + { + "area_mapping": area_mapping, + "last_seen_segments": [asdict(segment) for segment in mock_vacuum.segments], + }, + ) + await hass.async_block_till_done() + + assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None + + +@pytest.mark.usefixtures("config_flow_fixture") +async def test_no_segments_mapping_issue_without_clean_area( + hass: HomeAssistant, +) -> None: + """Test no repair issue is created when CLEAN_AREA is not supported.""" + mock_vacuum = MockVacuum(name="Testing", entity_id="vacuum.testing") + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + async_unload_entry=help_async_unload_entry, + ), + ) + setup_test_component_platform(hass, DOMAIN, [mock_vacuum], from_config_entry=True) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + issues = ir.async_get(hass).issues + assert not any( + issue_id[1].startswith("segments_mapping_not_configured") for issue_id in issues + ) + @pytest.mark.parametrize(("is_built_in", "log_warnings"), [(True, 0), (False, 3)]) async def test_vacuum_log_deprecated_battery_using_properties( diff --git a/tests/components/volvo/test_services.py b/tests/components/volvo/test_services.py new file mode 100644 index 0000000000000..2b67f7133db4f --- /dev/null +++ b/tests/components/volvo/test_services.py @@ -0,0 +1,240 @@ +"""Test Volvo services.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import AsyncMock, patch + +from httpx import AsyncClient, HTTPError, HTTPStatusError, Request, Response +import pytest + +from homeassistant.components.volvo.const import DOMAIN +from homeassistant.components.volvo.services import ( + CONF_CONFIG_ENTRY_ID, + CONF_IMAGE_TYPES, + SERVICE_GET_IMAGE_URL, + _async_image_exists, + _parse_exterior_image_url, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_api") +async def test_setup_services( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], +) -> None: + """Test setup of services.""" + assert await setup_integration() + + services = hass.services.async_services_for_domain(DOMAIN) + assert services + assert SERVICE_GET_IMAGE_URL in services + + +@pytest.mark.usefixtures("mock_api") +async def test_get_image_url_all( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + mock_config_entry: MockConfigEntry, +) -> None: + """Test if get_image_url returns all image types.""" + assert await setup_integration() + + with patch( + "homeassistant.components.volvo.services._async_image_exists", + new=AsyncMock(return_value=True), + ): + images = await hass.services.async_call( + DOMAIN, + SERVICE_GET_IMAGE_URL, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_IMAGE_TYPES: [], + }, + blocking=True, + return_response=True, + ) + + assert images + assert images["images"] + assert isinstance(images["images"], list) + assert len(images["images"]) == 9 + + +@pytest.mark.usefixtures("mock_api") +@pytest.mark.parametrize( + "image_type", + [ + "exterior_back", + "exterior_back_left", + "exterior_back_right", + "exterior_front", + "exterior_front_left", + "exterior_front_right", + "exterior_side_left", + "exterior_side_right", + "interior", + ], +) +async def test_get_image_url_selected( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + mock_config_entry: MockConfigEntry, + image_type: str, +) -> None: + """Test if get_image_url returns selected image types.""" + assert await setup_integration() + + with patch( + "homeassistant.components.volvo.services._async_image_exists", + new=AsyncMock(return_value=True), + ): + images = await hass.services.async_call( + DOMAIN, + SERVICE_GET_IMAGE_URL, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_IMAGE_TYPES: [image_type], + }, + blocking=True, + return_response=True, + ) + + assert images + assert images["images"] + assert isinstance(images["images"], list) + assert len(images["images"]) == 1 + + +@pytest.mark.usefixtures("mock_api") +@pytest.mark.parametrize( + ("entry_id", "translation_key"), + [ + ("", "invalid_entry_id"), + ("fake_entry_id", "invalid_entry"), + ("wrong_entry_id", "entry_not_found"), + ], +) +async def test_invalid_config_entry( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + entry_id: str, + translation_key: str, +) -> None: + """Test invalid config entry parameters.""" + assert await setup_integration() + + config_entry = MockConfigEntry(domain="fake_entry", entry_id="fake_entry_id") + config_entry.add_to_hass(hass) + + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_GET_IMAGE_URL, + { + CONF_CONFIG_ENTRY_ID: entry_id, + CONF_IMAGE_TYPES: [], + }, + blocking=True, + return_response=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == translation_key + + +@pytest.mark.usefixtures("mock_api") +async def test_invalid_image_type( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + mock_config_entry: MockConfigEntry, +) -> None: + """Test invalid image type parameters.""" + assert await setup_integration() + + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_GET_IMAGE_URL, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_IMAGE_TYPES: ["top"], + }, + blocking=True, + return_response=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "invalid_image_type" + + +async def test_async_image_exists(hass: HomeAssistant) -> None: + """Test _async_image_exists returns True on successful response.""" + client = AsyncMock(spec=AsyncClient) + response = AsyncMock() + response.raise_for_status.return_value = None + client.get.return_value = response + + assert await _async_image_exists(client, "http://example.com/image.jpg") + + +async def test_async_image_does_not_exist(hass: HomeAssistant) -> None: + """Test _async_image_exists returns False when image does not exist.""" + client = AsyncMock(spec=AsyncClient) + client.stream.side_effect = HTTPStatusError( + "Not found", + request=Request("GET", "http://example.com"), + response=Response(status_code=404), + ) + + assert not await _async_image_exists(client, "http://example.com/image.jpg") + + +async def test_async_image_non_404_status_error(hass: HomeAssistant) -> None: + """Test _async_image_exists raises HomeAssistantError on non-404 HTTP status errors.""" + client = AsyncMock(spec=AsyncClient) + client.stream.side_effect = HTTPStatusError( + "Internal server error", + request=Request("GET", "http://example.com"), + response=Response(status_code=500), + ) + + with pytest.raises(HomeAssistantError) as exc_info: + await _async_image_exists(client, "http://example.com/image.jpg") + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "image_error" + + +async def test_async_image_error(hass: HomeAssistant) -> None: + """Test _async_image_exists raises.""" + client = AsyncMock(spec=AsyncClient) + client.stream.side_effect = HTTPError("HTTP error") + + with pytest.raises(HomeAssistantError) as exc_info: + await _async_image_exists(client, "http://example.com/image.jpg") + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "image_error" + + +def test_parse_exterior_image_url_wizz_valid_angle() -> None: + """Replace angle segment in wizz-hosted URL when angle is valid.""" + src = "https://wizz.images.volvocars.com/images/threeQuartersRearLeft/abc123.jpg" + result = _parse_exterior_image_url(src, "6") + assert result == "https://wizz.images.volvocars.com/images/rear/abc123.jpg" + + +def test_parse_exterior_image_url_wizz_invalid_angle() -> None: + """Return empty string for wizz-hosted URL when angle is invalid.""" + src = "https://wizz.images.volvocars.com/images/front/xyz.jpg" + assert _parse_exterior_image_url(src, "9") == "" + + +def test_parse_exterior_image_url_non_wizz_sets_angle() -> None: + """Add angle query to non-wizz URL.""" + src = "https://images.volvocars.com/image?foo=bar&angle=1" + result = _parse_exterior_image_url(src, "3") + assert "angle=3" in result diff --git a/tests/components/zinvolt/fixtures/batteries.json b/tests/components/zinvolt/fixtures/batteries.json index 4746c40c3be45..e881ef28302f0 100644 --- a/tests/components/zinvolt/fixtures/batteries.json +++ b/tests/components/zinvolt/fixtures/batteries.json @@ -1,9 +1,9 @@ { "batteries": [ { - "id": "a0226fa5-bfdf-4192-9dd5-81d0ad085f29", + "id": "a125ef17-6bdf-45ad-b106-ce54e95e4634", "name": "Zinvolt Batterij", - "serial_number": "ALG001124100107" + "serial_number": "ZVG011025120088" } ] } diff --git a/tests/components/zinvolt/fixtures/current_state.json b/tests/components/zinvolt/fixtures/current_state.json index 36e5e5b29429e..7f2c93d2f096c 100644 --- a/tests/components/zinvolt/fixtures/current_state.json +++ b/tests/components/zinvolt/fixtures/current_state.json @@ -1,39 +1,38 @@ { - "sn": "ALG001124100107", + "sn": "ZVG011025120088", "name": "Zinvolt Batterij", - "longitude": 4.8936, - "latitude": 52.3792, "onlineStatus": "ONLINE", "currentPower": { - "soc": 4, - "coc": 0.04, - "pbt": 0, + "soc": 100, + "coc": 4, + "pbt": -19, "ppv": 0, - "pso": 0, + "pso": -19, "onGrid": true, "onlineStatus": "ONLINE", "smp": 800, - "isDormancy": false + "isDormancy": false, + "socCalibrateStatus": false }, - "smartMode": "DYNAMIC", + "smartMode": "CHARGED", "globalSettings": { "maxOutput": 800, "maxOutputLimit": 800, "maxOutputUnlocked": false, "batHighCap": 100, - "batUseCap": 25, - "maxChargePower": 900, + "batUseCap": 10, + "maxChargePower": 800, "feedModePower": { "modeType": "FIXED", "fixedPower": 200, "pvFeedLimitPower": 800, "equips": [] }, - "haveElectricityPrices": true, + "haveElectricityPrices": false, "standbyTime": 60 }, "tips": [], - "bpd": 493, + "bpd": 2119, "updating": { "units": [] }, @@ -44,8 +43,5 @@ }, "isShowStatistic": false, "meterReaders": [], - "isHomeDisplay": false, - "dynamicPriceStatus": "CONFIGURED", - "dynamicStrategyStatus": "CONFIGURED", "remindManualSocCalibration": true } diff --git a/tests/components/zinvolt/snapshots/test_binary_sensor.ambr b/tests/components/zinvolt/snapshots/test_binary_sensor.ambr new file mode 100644 index 0000000000000..6894b6c8c20d9 --- /dev/null +++ b/tests/components/zinvolt/snapshots/test_binary_sensor.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_all_entities[binary_sensor.zinvolt_batterij_grid_connection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.zinvolt_batterij_grid_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Grid connection', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid connection', + 'platform': 'zinvolt', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'on_grid', + 'unique_id': 'ZVG011025120088.on_grid', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.zinvolt_batterij_grid_connection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Zinvolt Batterij Grid connection', + }), + 'context': , + 'entity_id': 'binary_sensor.zinvolt_batterij_grid_connection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/zinvolt/snapshots/test_init.ambr b/tests/components/zinvolt/snapshots/test_init.ambr index 54e89898d1a88..8e43261d9cf72 100644 --- a/tests/components/zinvolt/snapshots/test_init.ambr +++ b/tests/components/zinvolt/snapshots/test_init.ambr @@ -14,7 +14,7 @@ 'identifiers': set({ tuple( 'zinvolt', - 'ALG001124100107', + 'ZVG011025120088', ), }), 'labels': set({ @@ -25,7 +25,7 @@ 'name': 'Zinvolt Batterij', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': 'ALG001124100107', + 'serial_number': 'ZVG011025120088', 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/zinvolt/snapshots/test_number.ambr b/tests/components/zinvolt/snapshots/test_number.ambr new file mode 100644 index 0000000000000..cd2061dcabad4 --- /dev/null +++ b/tests/components/zinvolt/snapshots/test_number.ambr @@ -0,0 +1,239 @@ +# serializer version: 1 +# name: test_all_entities[number.zinvolt_batterij_maximum_charge_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.zinvolt_batterij_maximum_charge_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Maximum charge level', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Maximum charge level', + 'platform': 'zinvolt', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'upper_threshold', + 'unique_id': 'ZVG011025120088.upper_threshold', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[number.zinvolt_batterij_maximum_charge_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Zinvolt Batterij Maximum charge level', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.zinvolt_batterij_maximum_charge_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[number.zinvolt_batterij_maximum_output-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 800, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.zinvolt_batterij_maximum_output', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Maximum output', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Maximum output', + 'platform': 'zinvolt', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_output', + 'unique_id': 'ZVG011025120088.max_output', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.zinvolt_batterij_maximum_output-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Zinvolt Batterij Maximum output', + 'max': 800, + 'min': 0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.zinvolt_batterij_maximum_output', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '800', + }) +# --- +# name: test_all_entities[number.zinvolt_batterij_minimum_charge_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 9, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.zinvolt_batterij_minimum_charge_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Minimum charge level', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Minimum charge level', + 'platform': 'zinvolt', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lower_threshold', + 'unique_id': 'ZVG011025120088.lower_threshold', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[number.zinvolt_batterij_minimum_charge_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Zinvolt Batterij Minimum charge level', + 'max': 100, + 'min': 9, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.zinvolt_batterij_minimum_charge_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_all_entities[number.zinvolt_batterij_standby_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 60, + 'min': 5, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.zinvolt_batterij_standby_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Standby time', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Standby time', + 'platform': 'zinvolt', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'standby_time', + 'unique_id': 'ZVG011025120088.standby_time', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.zinvolt_batterij_standby_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Zinvolt Batterij Standby time', + 'max': 60, + 'min': 5, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.zinvolt_batterij_standby_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- diff --git a/tests/components/zinvolt/snapshots/test_sensor.ambr b/tests/components/zinvolt/snapshots/test_sensor.ambr index 77d2d510d48e5..04feec0df3830 100644 --- a/tests/components/zinvolt/snapshots/test_sensor.ambr +++ b/tests/components/zinvolt/snapshots/test_sensor.ambr @@ -31,7 +31,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'ALG001124100107.state_of_charge', + 'unique_id': 'ZVG011025120088.state_of_charge', 'unit_of_measurement': '%', }) # --- @@ -47,6 +47,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '4.0', + 'state': '100.0', }) # --- diff --git a/tests/components/zinvolt/test_binary_sensor.py b/tests/components/zinvolt/test_binary_sensor.py new file mode 100644 index 0000000000000..72e14bcd466b3 --- /dev/null +++ b/tests/components/zinvolt/test_binary_sensor.py @@ -0,0 +1,27 @@ +"""Tests for the Zinvolt binary sensor.""" + +from unittest.mock import AsyncMock, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_zinvolt_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.zinvolt._PLATFORMS", [Platform.BINARY_SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/zinvolt/test_init.py b/tests/components/zinvolt/test_init.py index 825caca10c665..5c755d987bd72 100644 --- a/tests/components/zinvolt/test_init.py +++ b/tests/components/zinvolt/test_init.py @@ -22,6 +22,6 @@ async def test_device( ) -> None: """Test the Zinvolt device.""" await setup_integration(hass, mock_config_entry) - device = device_registry.async_get_device({(DOMAIN, "ALG001124100107")}) + device = device_registry.async_get_device({(DOMAIN, "ZVG011025120088")}) assert device assert device == snapshot diff --git a/tests/components/zinvolt/test_number.py b/tests/components/zinvolt/test_number.py new file mode 100644 index 0000000000000..8afa9e4606d43 --- /dev/null +++ b/tests/components/zinvolt/test_number.py @@ -0,0 +1,27 @@ +"""Tests for the Zinvolt number.""" + +from unittest.mock import AsyncMock, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_zinvolt_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.zinvolt._PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/pylint/test_enforce_sorted_platforms.py b/tests/pylint/test_enforce_sorted_platforms.py index d1e6d500cc313..ad62ddf38e393 100644 --- a/tests/pylint/test_enforce_sorted_platforms.py +++ b/tests/pylint/test_enforce_sorted_platforms.py @@ -40,6 +40,30 @@ """, id="typed_multiple_platform", ), + pytest.param( + """ + _PLATFORMS = [Platform.SENSOR] + """, + id="private_one_platform", + ), + pytest.param( + """ + _PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR] + """, + id="private_multiple_platforms", + ), + pytest.param( + """ + _PLATFORMS: list[str] = [Platform.SENSOR] + """, + id="private_typed_one_platform", + ), + pytest.param( + """ + _PLATFORMS: list[str] = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR] + """, + id="private_typed_multiple_platforms", + ), ], ) def test_enforce_sorted_platforms( @@ -110,3 +134,59 @@ def test_enforce_sorted_platforms_bad_typed( ), ): enforce_sorted_platforms_checker.visit_annassign(assign_node) + + +def test_enforce_sorted_private_platforms_bad( + linter: UnittestLinter, + enforce_sorted_platforms_checker: BaseChecker, +) -> None: + """Bad test case for private _PLATFORMS.""" + assign_node = astroid.extract_node( + """ + _PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.BUTTON] + """, + "homeassistant.components.pylint_test", + ) + + with assert_adds_messages( + linter, + MessageTest( + msg_id="hass-enforce-sorted-platforms", + line=2, + node=assign_node, + args=None, + confidence=UNDEFINED, + col_offset=0, + end_line=2, + end_col_offset=71, + ), + ): + enforce_sorted_platforms_checker.visit_assign(assign_node) + + +def test_enforce_sorted_private_platforms_bad_typed( + linter: UnittestLinter, + enforce_sorted_platforms_checker: BaseChecker, +) -> None: + """Bad typed test case for private _PLATFORMS.""" + assign_node = astroid.extract_node( + """ + _PLATFORMS: list[str] = [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.BUTTON] + """, + "homeassistant.components.pylint_test", + ) + + with assert_adds_messages( + linter, + MessageTest( + msg_id="hass-enforce-sorted-platforms", + line=2, + node=assign_node, + args=None, + confidence=UNDEFINED, + col_offset=0, + end_line=2, + end_col_offset=82, + ), + ): + enforce_sorted_platforms_checker.visit_annassign(assign_node)