diff --git a/.gitignore b/.gitignore index 81f32c69edad14..0b8ee4c7b5338c 100644 --- a/.gitignore +++ b/.gitignore @@ -141,4 +141,5 @@ pytest_buckets.txt # AI tooling .claude/settings.local.json +.serena/ diff --git a/homeassistant/components/control4/__init__.py b/homeassistant/components/control4/__init__.py index 59216e4a863888..6fa6a51b94098c 100644 --- a/homeassistant/components/control4/__init__.py +++ b/homeassistant/components/control4/__init__.py @@ -34,7 +34,7 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.LIGHT, Platform.MEDIA_PLAYER] +PLATFORMS = [Platform.CLIMATE, Platform.LIGHT, Platform.MEDIA_PLAYER] @dataclass diff --git a/homeassistant/components/control4/climate.py b/homeassistant/components/control4/climate.py new file mode 100644 index 00000000000000..3626260051cf13 --- /dev/null +++ b/homeassistant/components/control4/climate.py @@ -0,0 +1,301 @@ +"""Platform for Control4 Climate/Thermostat.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Any + +from pyControl4.climate import C4Climate +from pyControl4.error_handling import C4Exception + +from homeassistant.components.climate import ( + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from . import Control4ConfigEntry, Control4RuntimeData, get_items_of_category +from .const import CONTROL4_ENTITY_TYPE +from .director_utils import update_variables_for_config_entry +from .entity import Control4Entity + +_LOGGER = logging.getLogger(__name__) + +CONTROL4_CATEGORY = "comfort" + +# Control4 variable names +CONTROL4_HVAC_STATE = "HVAC_STATE" +CONTROL4_HVAC_MODE = "HVAC_MODE" +CONTROL4_CURRENT_TEMPERATURE = "TEMPERATURE_F" +CONTROL4_HUMIDITY = "HUMIDITY" +CONTROL4_COOL_SETPOINT = "COOL_SETPOINT_F" +CONTROL4_HEAT_SETPOINT = "HEAT_SETPOINT_F" + +VARIABLES_OF_INTEREST = { + CONTROL4_HVAC_STATE, + CONTROL4_HVAC_MODE, + CONTROL4_CURRENT_TEMPERATURE, + CONTROL4_HUMIDITY, + CONTROL4_COOL_SETPOINT, + CONTROL4_HEAT_SETPOINT, +} + +# Map Control4 HVAC modes to Home Assistant +C4_TO_HA_HVAC_MODE = { + "Off": HVACMode.OFF, + "Cool": HVACMode.COOL, + "Heat": HVACMode.HEAT, + "Auto": HVACMode.HEAT_COOL, +} + +HA_TO_C4_HVAC_MODE = {v: k for k, v in C4_TO_HA_HVAC_MODE.items()} + +# Map Control4 HVAC state to Home Assistant HVAC action +C4_TO_HA_HVAC_ACTION = { + "heating": HVACAction.HEATING, + "cooling": HVACAction.COOLING, + "idle": HVACAction.IDLE, + "off": HVACAction.OFF, +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: Control4ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Control4 thermostats from a config entry.""" + runtime_data = entry.runtime_data + + async def async_update_data() -> dict[int, dict[str, Any]]: + """Fetch data from Control4 director for thermostats.""" + try: + return await update_variables_for_config_entry( + hass, entry, VARIABLES_OF_INTEREST + ) + except C4Exception as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + + coordinator = DataUpdateCoordinator[dict[int, dict[str, Any]]]( + hass, + _LOGGER, + name="climate", + update_method=async_update_data, + update_interval=timedelta(seconds=runtime_data.scan_interval), + config_entry=entry, + ) + + # Fetch initial data so we have data when entities subscribe + await coordinator.async_refresh() + + items_of_category = await get_items_of_category(hass, entry, CONTROL4_CATEGORY) + entity_list = [] + for item in items_of_category: + try: + if item["type"] == CONTROL4_ENTITY_TYPE: + item_name = item["name"] + item_id = item["id"] + item_parent_id = item["parentId"] + item_manufacturer = None + item_device_name = None + item_model = None + + for parent_item in items_of_category: + if parent_item["id"] == item_parent_id: + item_manufacturer = parent_item.get("manufacturer") + item_device_name = parent_item.get("roomName") + item_model = parent_item.get("model") + else: + continue + except KeyError: + _LOGGER.exception( + "Unknown device properties received from Control4: %s", + item, + ) + continue + + # Skip if we don't have data for this thermostat + if item_id not in coordinator.data: + _LOGGER.warning( + "Couldn't get climate state data for %s (ID: %s), skipping setup", + item_name, + item_id, + ) + continue + + entity_list.append( + Control4Climate( + runtime_data, + coordinator, + item_name, + item_id, + item_device_name, + item_manufacturer, + item_model, + item_parent_id, + ) + ) + + async_add_entities(entity_list) + + +class Control4Climate(Control4Entity, ClimateEntity): + """Control4 climate entity.""" + + _attr_has_entity_name = True + _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + ) + _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL] + + def __init__( + self, + runtime_data: Control4RuntimeData, + coordinator: DataUpdateCoordinator[dict[int, dict[str, Any]]], + name: str, + idx: int, + device_name: str | None, + device_manufacturer: str | None, + device_model: str | None, + device_id: int, + ) -> None: + """Initialize Control4 climate entity.""" + super().__init__( + runtime_data, + coordinator, + name, + idx, + device_name, + device_manufacturer, + device_model, + device_id, + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self._thermostat_data is not None + + def _create_api_object(self) -> C4Climate: + """Create a pyControl4 device object. + + This exists so the director token used is always the latest one, without needing to re-init the entire entity. + """ + return C4Climate(self.runtime_data.director, self._idx) + + @property + def _thermostat_data(self) -> dict[str, Any] | None: + """Return the thermostat data from the coordinator.""" + return self.coordinator.data.get(self._idx) + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + data = self._thermostat_data + if data is None: + return None + return data.get(CONTROL4_CURRENT_TEMPERATURE) + + @property + def current_humidity(self) -> int | None: + """Return the current humidity.""" + data = self._thermostat_data + if data is None: + return None + humidity = data.get(CONTROL4_HUMIDITY) + return int(humidity) if humidity is not None else None + + @property + def hvac_mode(self) -> HVACMode: + """Return current HVAC mode.""" + data = self._thermostat_data + if data is None: + return HVACMode.OFF + c4_mode = data.get(CONTROL4_HVAC_MODE) or "" + return C4_TO_HA_HVAC_MODE.get(c4_mode, HVACMode.OFF) + + @property + def hvac_action(self) -> HVACAction | None: + """Return current HVAC action.""" + data = self._thermostat_data + if data is None: + return None + c4_state = data.get(CONTROL4_HVAC_STATE) + if c4_state is None: + return None + # Convert state to lowercase for mapping + return C4_TO_HA_HVAC_ACTION.get(str(c4_state).lower()) + + @property + def target_temperature(self) -> float | None: + """Return the target temperature.""" + data = self._thermostat_data + if data is None: + return None + hvac_mode = self.hvac_mode + if hvac_mode == HVACMode.COOL: + return data.get(CONTROL4_COOL_SETPOINT) + if hvac_mode == HVACMode.HEAT: + return data.get(CONTROL4_HEAT_SETPOINT) + return None + + @property + def target_temperature_high(self) -> float | None: + """Return the high target temperature for auto mode.""" + data = self._thermostat_data + if data is None: + return None + if self.hvac_mode == HVACMode.HEAT_COOL: + return data.get(CONTROL4_COOL_SETPOINT) + return None + + @property + def target_temperature_low(self) -> float | None: + """Return the low target temperature for auto mode.""" + data = self._thermostat_data + if data is None: + return None + if self.hvac_mode == HVACMode.HEAT_COOL: + return data.get(CONTROL4_HEAT_SETPOINT) + return None + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target HVAC mode.""" + c4_hvac_mode = HA_TO_C4_HVAC_MODE[hvac_mode] + c4_climate = self._create_api_object() + await c4_climate.setHvacMode(c4_hvac_mode) + await self.coordinator.async_request_refresh() + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + c4_climate = self._create_api_object() + low_temp = kwargs.get(ATTR_TARGET_TEMP_LOW) + high_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH) + temp = kwargs.get(ATTR_TEMPERATURE) + + # Handle temperature range for auto mode + if self.hvac_mode == HVACMode.HEAT_COOL: + if low_temp is not None: + await c4_climate.setHeatSetpointF(low_temp) + if high_temp is not None: + await c4_climate.setCoolSetpointF(high_temp) + # Handle single temperature setpoint + elif temp is not None: + if self.hvac_mode == HVACMode.COOL: + await c4_climate.setCoolSetpointF(temp) + elif self.hvac_mode == HVACMode.HEAT: + await c4_climate.setHeatSetpointF(temp) + + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/droplet/manifest.json b/homeassistant/components/droplet/manifest.json index f4a03ebfb21afc..80ded2d23a76d3 100644 --- a/homeassistant/components/droplet/manifest.json +++ b/homeassistant/components/droplet/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/droplet", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["pydroplet==2.3.3"], + "requirements": ["pydroplet==2.3.4"], "zeroconf": ["_droplet._tcp.local."] } diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 1653c33e5ecb26..b93f25142c58b5 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -109,6 +109,8 @@ DATA_KEY_SUPERVISOR_ISSUES = "supervisor_issues" PLACEHOLDER_KEY_ADDON = "addon" +PLACEHOLDER_KEY_ADDON_INFO = "addon_info" +PLACEHOLDER_KEY_ADDON_DOCUMENTATION = "addon_documentation" PLACEHOLDER_KEY_ADDON_URL = "addon_url" PLACEHOLDER_KEY_REFERENCE = "reference" PLACEHOLDER_KEY_COMPONENTS = "components" @@ -120,6 +122,7 @@ ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED = "issue_addon_detached_addon_removed" ISSUE_KEY_ADDON_PWNED = "issue_addon_pwned" ISSUE_KEY_SYSTEM_FREE_SPACE = "issue_system_free_space" +ISSUE_KEY_ADDON_DEPRECATED = "issue_addon_deprecated_addon" CORE_CONTAINER = "homeassistant" SUPERVISOR_CONTAINER = "hassio_supervisor" @@ -156,6 +159,7 @@ ISSUE_KEY_ADDON_PWNED: { "more_info_pwned": "https://www.home-assistant.io/more-info/pwned-passwords", }, + ISSUE_KEY_ADDON_DEPRECATED: HELP_URLS, } diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index df1ca87fe0b6d9..3ec1f5389ce026 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -43,6 +43,7 @@ EVENT_SUPPORTED_CHANGED, EXTRA_PLACEHOLDERS, ISSUE_KEY_ADDON_BOOT_FAIL, + ISSUE_KEY_ADDON_DEPRECATED, ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING, ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, ISSUE_KEY_ADDON_PWNED, @@ -84,6 +85,7 @@ "issue_system_disk_lifetime", ISSUE_KEY_SYSTEM_FREE_SPACE, ISSUE_KEY_ADDON_PWNED, + ISSUE_KEY_ADDON_DEPRECATED, } _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py index ff32e2cbab9768..de90026be5b17f 100644 --- a/homeassistant/components/hassio/repairs.py +++ b/homeassistant/components/hassio/repairs.py @@ -18,10 +18,13 @@ from .const import ( EXTRA_PLACEHOLDERS, ISSUE_KEY_ADDON_BOOT_FAIL, + ISSUE_KEY_ADDON_DEPRECATED, ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, ISSUE_KEY_ADDON_PWNED, ISSUE_KEY_SYSTEM_DOCKER_CONFIG, PLACEHOLDER_KEY_ADDON, + PLACEHOLDER_KEY_ADDON_DOCUMENTATION, + PLACEHOLDER_KEY_ADDON_INFO, PLACEHOLDER_KEY_COMPONENTS, PLACEHOLDER_KEY_REFERENCE, ) @@ -195,6 +198,23 @@ def description_placeholders(self) -> dict[str, str] | None: return placeholders or None +class DeprecatedAddonIssueRepairFlow(AddonIssueRepairFlow): + """Handler for deprecated addon issue fixing flows.""" + + @property + def description_placeholders(self) -> dict[str, str] | None: + """Get description placeholders for steps.""" + placeholders: dict[str, str] = super().description_placeholders or {} + if self.issue and self.issue.reference: + placeholders[PLACEHOLDER_KEY_ADDON_INFO] = ( + f"homeassistant://hassio/addon/{self.issue.reference}/info" + ) + placeholders[PLACEHOLDER_KEY_ADDON_DOCUMENTATION] = ( + f"homeassistant://hassio/addon/{self.issue.reference}/documentation" + ) + return placeholders or None + + async def async_create_fix_flow( hass: HomeAssistant, issue_id: str, @@ -205,6 +225,8 @@ async def async_create_fix_flow( issue = supervisor_issues and supervisor_issues.get_issue(issue_id) if issue and issue.key == ISSUE_KEY_SYSTEM_DOCKER_CONFIG: return DockerConfigIssueRepairFlow(hass, issue_id) + if issue and issue.key == ISSUE_KEY_ADDON_DEPRECATED: + return DeprecatedAddonIssueRepairFlow(hass, issue_id) if issue and issue.key in { ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, ISSUE_KEY_ADDON_BOOT_FAIL, diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index d93fff8d06d6bb..1c3e51069c1d04 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -56,6 +56,19 @@ "title": "Insecure secrets detected in add-on configuration", "description": "Add-on {addon} uses secrets/passwords in its configuration which are detected as not secure. See [pwned passwords and secrets]({more_info_pwned}) for more information on this issue." }, + "issue_addon_deprecated_addon": { + "title": "Installed add-on is deprecated", + "fix_flow": { + "step": { + "addon_execute_remove": { + "description": "Add-on {addon} is marked deprecated by the developer. This means it is no longer being maintained and so may break or become a security issue over time.\n\nReview the [readme]({addon_info}) and [documentation]({addon_documentation}) of the add-on to see if the developer provided instructions.\n\nSelecting **Submit** will uninstall this deprecated add-on. Alternatively, you can check [Home Assistant help]({help_url}) and the [community forum]({community_url}) for alternatives to migrate to." + } + }, + "abort": { + "apply_suggestion_fail": "Could not uninstall the add-on. Check the Supervisor logs for more details." + } + } + }, "issue_mount_mount_failed": { "title": "Network storage device failed", "fix_flow": { diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index e0aee9b5c2a00f..6cd21c7f6b3ac1 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.82", "babel==2.15.0"] + "requirements": ["holidays==0.83", "babel==2.15.0"] } diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index 92c6e38a6102ca..0112167dbf3168 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -123,6 +123,9 @@ "evse_fault_state": { "default": "mdi:ev-station" }, + "operational_error": { + "default": "mdi:alert-circle" + }, "pump_control_mode": { "default": "mdi:pipe-wrench" }, diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 8784a1e0696002..f7603c9fde9f29 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -86,6 +86,14 @@ clusters.OperationalState.Enums.OperationalStateEnum.kError: "error", } +OPERATIONAL_STATE_ERROR_MAP = { + # enum with known Error state values which we can translate + clusters.OperationalState.Enums.ErrorStateEnum.kNoError: "no_error", + clusters.OperationalState.Enums.ErrorStateEnum.kUnableToStartOrResume: "unable_to_start_or_resume", + clusters.OperationalState.Enums.ErrorStateEnum.kUnableToCompleteOperation: "unable_to_complete_operation", + clusters.OperationalState.Enums.ErrorStateEnum.kCommandInvalidInState: "command_invalid_in_state", +} + RVC_OPERATIONAL_STATE_MAP = { # enum with known Operation state values which we can translate **OPERATIONAL_STATE_MAP, @@ -94,6 +102,29 @@ clusters.RvcOperationalState.Enums.OperationalStateEnum.kDocked: "docked", } +RVC_OPERATIONAL_STATE_ERROR_MAP = { + # enum with known Error state values which we can translate + clusters.RvcOperationalState.Enums.ErrorStateEnum.kNoError: "no_error", + clusters.RvcOperationalState.Enums.ErrorStateEnum.kUnableToStartOrResume: "unable_to_start_or_resume", + clusters.RvcOperationalState.Enums.ErrorStateEnum.kUnableToCompleteOperation: "unable_to_complete_operation", + clusters.RvcOperationalState.Enums.ErrorStateEnum.kCommandInvalidInState: "command_invalid_in_state", + clusters.RvcOperationalState.Enums.ErrorStateEnum.kFailedToFindChargingDock: "failed_to_find_charging_dock", + clusters.RvcOperationalState.Enums.ErrorStateEnum.kStuck: "stuck", + clusters.RvcOperationalState.Enums.ErrorStateEnum.kDustBinMissing: "dust_bin_missing", + clusters.RvcOperationalState.Enums.ErrorStateEnum.kDustBinFull: "dust_bin_full", + clusters.RvcOperationalState.Enums.ErrorStateEnum.kWaterTankEmpty: "water_tank_empty", + clusters.RvcOperationalState.Enums.ErrorStateEnum.kWaterTankMissing: "water_tank_missing", + clusters.RvcOperationalState.Enums.ErrorStateEnum.kWaterTankLidOpen: "water_tank_lid_open", + clusters.RvcOperationalState.Enums.ErrorStateEnum.kMopCleaningPadMissing: "mop_cleaning_pad_missing", + clusters.RvcOperationalState.Enums.ErrorStateEnum.kLowBattery: "low_battery", + clusters.RvcOperationalState.Enums.ErrorStateEnum.kCannotReachTargetArea: "cannot_reach_target_area", + clusters.RvcOperationalState.Enums.ErrorStateEnum.kDirtyWaterTankFull: "dirty_water_tank_full", + clusters.RvcOperationalState.Enums.ErrorStateEnum.kDirtyWaterTankMissing: "dirty_water_tank_missing", + clusters.RvcOperationalState.Enums.ErrorStateEnum.kWheelsJammed: "wheels_jammed", + clusters.RvcOperationalState.Enums.ErrorStateEnum.kBrushJammed: "brush_jammed", + clusters.RvcOperationalState.Enums.ErrorStateEnum.kNavigationSensorObscured: "navigation_sensor_obscured", +} + BOOST_STATE_MAP = { clusters.WaterHeaterManagement.Enums.BoostStateEnum.kInactive: "inactive", clusters.WaterHeaterManagement.Enums.BoostStateEnum.kActive: "active", @@ -1101,6 +1132,19 @@ def _update_from_device(self) -> None: # don't discover this entry if the supported state list is empty secondary_value_is_not=[], ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="OperationalStateOperationalError", + translation_key="operational_error", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=list(OPERATIONAL_STATE_ERROR_MAP.values()), + device_to_ha=lambda x: OPERATIONAL_STATE_ERROR_MAP.get(x.errorStateID), + ), + entity_class=MatterSensor, + required_attributes=(clusters.OperationalState.Attributes.OperationalError,), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterListSensorEntityDescription( @@ -1145,6 +1189,19 @@ def _update_from_device(self) -> None: device_type=(device_types.Thermostat,), allow_multi=True, # also used for climate entity ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ThermostatPIHeatingDemand", + translation_key="pi_heating_demand", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + entity_class=MatterSensor, + required_attributes=(clusters.Thermostat.Attributes.PIHeatingDemand,), + device_type=(device_types.Thermostat,), + featuremap_contains=clusters.Thermostat.Bitmaps.Feature.kHeating, + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( @@ -1181,6 +1238,19 @@ def _update_from_device(self) -> None: # don't discover this entry if the supported state list is empty secondary_value_is_not=[], ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="RvcOperationalStateOperationalError", + translation_key="operational_error", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=list(RVC_OPERATIONAL_STATE_ERROR_MAP.values()), + device_to_ha=lambda x: RVC_OPERATIONAL_STATE_ERROR_MAP.get(x.errorStateID), + ), + entity_class=MatterSensor, + required_attributes=(clusters.RvcOperationalState.Attributes.OperationalError,), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterOperationalStateSensorEntityDescription( diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 6766cd57e5e652..0c0ffc84715eb3 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -441,6 +441,33 @@ "evse_soc": { "name": "State of charge" }, + "operational_error": { + "name": "Operational error", + "state": { + "no_error": "No error", + "unable_to_start_or_resume": "Unable to start or resume", + "unable_to_complete_operation": "Unable to complete operation", + "command_invalid_in_state": "Command invalid in current state", + "failed_to_find_charging_dock": "Failed to find charging dock", + "stuck": "Stuck", + "dust_bin_missing": "Dust bin missing", + "dust_bin_full": "Dust bin full", + "water_tank_empty": "Water tank empty", + "water_tank_missing": "Water tank missing", + "water_tank_lid_open": "Water tank lid open", + "mop_cleaning_pad_missing": "Mop cleaning pad missing", + "low_battery": "Low battery", + "cannot_reach_target_area": "Cannot reach target area", + "dirty_water_tank_full": "Dirty water tank full", + "dirty_water_tank_missing": "Dirty water tank missing", + "wheels_jammed": "Wheels jammed", + "brush_jammed": "Brush jammed", + "navigation_sensor_obscured": "Navigation sensor obscured" + } + }, + "pi_heating_demand": { + "name": "Heating demand" + }, "nitrogen_dioxide": { "name": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]" }, diff --git a/homeassistant/components/niko_home_control/__init__.py b/homeassistant/components/niko_home_control/__init__.py index 37396e69caaecb..650d9f05b84dbd 100644 --- a/homeassistant/components/niko_home_control/__init__.py +++ b/homeassistant/components/niko_home_control/__init__.py @@ -12,7 +12,7 @@ from .const import _LOGGER -PLATFORMS: list[Platform] = [Platform.COVER, Platform.LIGHT] +PLATFORMS: list[Platform] = [Platform.COVER, Platform.LIGHT, Platform.SCENE] type NikoHomeControlConfigEntry = ConfigEntry[NHCController] diff --git a/homeassistant/components/niko_home_control/scene.py b/homeassistant/components/niko_home_control/scene.py new file mode 100644 index 00000000000000..129b946b748a9f --- /dev/null +++ b/homeassistant/components/niko_home_control/scene.py @@ -0,0 +1,40 @@ +"""Scene Platform for Niko Home Control.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.scene import BaseScene +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import NikoHomeControlConfigEntry +from .entity import NikoHomeControlEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NikoHomeControlConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Niko Home Control scene entry.""" + controller = entry.runtime_data + + async_add_entities( + NikoHomeControlScene(scene, controller, entry.entry_id) + for scene in controller.scenes + ) + + +class NikoHomeControlScene(NikoHomeControlEntity, BaseScene): + """Representation of a Niko Home Control Scene.""" + + _attr_name = None + + async def _async_activate(self, **kwargs: Any) -> None: + """Activate scene. Try to get entities into requested state.""" + await self._action.activate() + + def update_state(self) -> None: + """Update HA state.""" + self._async_record_activation() diff --git a/homeassistant/components/octoprint/__init__.py b/homeassistant/components/octoprint/__init__.py index 48d81b81f0cc62..a582832d4776e8 100644 --- a/homeassistant/components/octoprint/__init__.py +++ b/homeassistant/components/octoprint/__init__.py @@ -56,7 +56,13 @@ def ensure_valid_path(value): return value -PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CAMERA, Platform.SENSOR] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.CAMERA, + Platform.NUMBER, + Platform.SENSOR, +] DEFAULT_NAME = "OctoPrint" CONF_NUMBER_OF_TOOLS = "number_of_tools" CONF_BED = "bed" diff --git a/homeassistant/components/octoprint/number.py b/homeassistant/components/octoprint/number.py new file mode 100644 index 00000000000000..93fa32a9e33f89 --- /dev/null +++ b/homeassistant/components/octoprint/number.py @@ -0,0 +1,146 @@ +"""Support for OctoPrint number entities.""" + +from __future__ import annotations + +import logging + +from pyoctoprintapi import OctoprintClient + +from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import OctoprintDataUpdateCoordinator +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +def is_bed(tool_name: str) -> bool: + """Return True if the tool name indicates a bed.""" + return tool_name == "bed" + + +def is_extruder(tool_name: str) -> bool: + """Return True if the tool name indicates an extruder.""" + return tool_name.startswith("tool") and tool_name[4:].isdigit() + + +def is_first_extruder(tool_name: str) -> bool: + """Return True if the tool name indicates the first extruder.""" + return tool_name == "tool0" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the OctoPrint number entities.""" + coordinator: OctoprintDataUpdateCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ]["coordinator"] + client: OctoprintClient = hass.data[DOMAIN][config_entry.entry_id]["client"] + device_id = config_entry.unique_id + + assert device_id is not None + + known_tools = set() + + @callback + def async_add_tool_numbers() -> None: + if not coordinator.data["printer"]: + return + + new_numbers: list[OctoPrintTemperatureNumber] = [] + for tool in coordinator.data["printer"].temperatures: + if ( + is_extruder(tool.name) or is_bed(tool.name) + ) and tool.name not in known_tools: + assert device_id is not None + known_tools.add(tool.name) + new_numbers.append( + OctoPrintTemperatureNumber( + coordinator, + client, + tool.name, + device_id, + ) + ) + async_add_entities(new_numbers) + + config_entry.async_on_unload(coordinator.async_add_listener(async_add_tool_numbers)) + + if coordinator.data["printer"]: + async_add_tool_numbers() + + +class OctoPrintTemperatureNumber( + CoordinatorEntity[OctoprintDataUpdateCoordinator], NumberEntity +): + """Representation of an OctoPrint temperature setter entity.""" + + _attr_has_entity_name = True + _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + _attr_native_min_value = 0 + _attr_native_max_value = 300 + _attr_native_step = 1 + _attr_mode = NumberMode.BOX + _attr_device_class = NumberDeviceClass.TEMPERATURE + + def __init__( + self, + coordinator: OctoprintDataUpdateCoordinator, + client: OctoprintClient, + tool: str, + device_id: str, + ) -> None: + """Initialize a new OctoPrint temperature number entity.""" + super().__init__(coordinator) + self._api_tool = tool + self._attr_device_info = coordinator.device_info + self._attr_unique_id = f"{device_id}_{tool}_temperature" + self._client = client + self._device_id = device_id + if is_bed(tool): + self._attr_translation_key = "bed_temperature" + elif is_first_extruder(tool): + self._attr_translation_key = "extruder_temperature" + else: + self._attr_translation_key = "extruder_n_temperature" + self._attr_translation_placeholders = {"n": tool[4:]} + + @property + def native_value(self) -> float | None: + """Return the current target temperature.""" + if not self.coordinator.data["printer"]: + return None + for tool in self.coordinator.data["printer"].temperatures: + if tool.name == self._api_tool and tool.target_temp is not None: + return tool.target_temp + + return None + + async def async_set_native_value(self, value: float) -> None: + """Set the target temperature.""" + + try: + if is_bed(self._api_tool): + await self._client.set_bed_temperature(int(value)) + elif is_extruder(self._api_tool): + await self._client.set_tool_temperature(self._api_tool, int(value)) + except Exception as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="error_setting_temperature", + translation_placeholders={ + "tool": self._api_tool, + }, + ) from err + + # Request coordinator update to reflect the change + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/octoprint/strings.json b/homeassistant/components/octoprint/strings.json index 7f08d04e3da305..a516977efd2c91 100644 --- a/homeassistant/components/octoprint/strings.json +++ b/homeassistant/components/octoprint/strings.json @@ -36,7 +36,23 @@ "get_api_key": "Open the OctoPrint UI and select **Allow** on the Access Request for **Home Assistant**." } }, + "entity": { + "number": { + "bed_temperature": { + "name": "Bed temperature" + }, + "extruder_temperature": { + "name": "Extruder temperature" + }, + "extruder_n_temperature": { + "name": "Extruder {n} temperature" + } + } + }, "exceptions": { + "error_setting_temperature": { + "message": "Error setting target {tool} temperature" + }, "missing_client": { "message": "No client for device ID: {device_id}" } diff --git a/homeassistant/components/portainer/manifest.json b/homeassistant/components/portainer/manifest.json index a8d84c8d6a252c..8e6c204ee5310b 100644 --- a/homeassistant/components/portainer/manifest.json +++ b/homeassistant/components/portainer/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/portainer", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["pyportainer==1.0.4"] + "requirements": ["pyportainer==1.0.7"] } diff --git a/homeassistant/components/thethingsnetwork/manifest.json b/homeassistant/components/thethingsnetwork/manifest.json index 8d826750e39e56..139a28c4d980d7 100644 --- a/homeassistant/components/thethingsnetwork/manifest.json +++ b/homeassistant/components/thethingsnetwork/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/thethingsnetwork", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["ttn_client==1.2.0"] + "requirements": ["ttn_client==1.2.2"] } diff --git a/homeassistant/components/uptime_kuma/coordinator.py b/homeassistant/components/uptime_kuma/coordinator.py index df64b12f8e9bba..98f452bf7a8cf5 100644 --- a/homeassistant/components/uptime_kuma/coordinator.py +++ b/homeassistant/components/uptime_kuma/coordinator.py @@ -19,7 +19,7 @@ from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -89,7 +89,8 @@ def async_migrate_entities_unique_ids( """Migrate unique_ids in the entity registry after updating Uptime Kuma.""" if ( - coordinator.version is coordinator.api.version + coordinator.version is None + or coordinator.version.version == coordinator.api.version.version or int(coordinator.api.version.major) < 2 ): return @@ -116,6 +117,32 @@ def async_migrate_entities_unique_ids( new_unique_id=f"{registry_entry.config_entry_id}_{monitor.monitor_id!s}_{registry_entry.translation_key}", ) + # migrate device identifiers and update version + device_reg = dr.async_get(hass) + for monitor in metrics.values(): + if device := device_reg.async_get_device( + {(DOMAIN, f"{coordinator.config_entry.entry_id}_{monitor.monitor_name!s}")} + ): + new_identifier = { + (DOMAIN, f"{coordinator.config_entry.entry_id}_{monitor.monitor_id!s}") + } + device_reg.async_update_device( + device.id, + new_identifiers=new_identifier, + sw_version=coordinator.api.version.version, + ) + if device := device_reg.async_get_device( + {(DOMAIN, f"{coordinator.config_entry.entry_id}_update")} + ): + device_reg.async_update_device( + device.id, + sw_version=coordinator.api.version.version, + ) + + hass.async_create_task( + hass.config_entries.async_reload(coordinator.config_entry.entry_id) + ) + class UptimeKumaSoftwareUpdateCoordinator(DataUpdateCoordinator[LatestRelease]): """Uptime Kuma coordinator for retrieving update information.""" diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index d0b420fe5c63c3..85fd43362e283e 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.82"] + "requirements": ["holidays==0.83"] } diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 62813871e9d441..3ff47d762a1785 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -3214,13 +3214,11 @@ def async_create_entry( # type: ignore[override] ) -> ConfigFlowResult: """Finish config flow and create a config entry.""" if self.source in {SOURCE_REAUTH, SOURCE_RECONFIGURE}: - report_usage( - f"creates a new entry in a '{self.source}' flow, " - "when it is expected to update an existing entry and abort", - core_behavior=ReportBehavior.LOG, - breaks_in_ha_version="2025.11", - integration_domain=self.handler, + raise HomeAssistantError( + f"Creates a new entry in a '{self.source}' flow, " + "when it is expected to update an existing entry and abort" ) + result = super().async_create_entry( title=title, data=data, diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 2b4da38b15efb5..7ed12467c6f46c 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -698,7 +698,13 @@ def traced_test_conditions( if cond(hass, variables) is False: return False except exceptions.ConditionError as ex: - _LOGGER.warning("Error in '%s[%s]' evaluation: %s", name, idx, ex) + self._log( + "Error in '%s[%s]' evaluation: %s", + name, + idx, + ex, + level=logging.WARNING, + ) return None return True @@ -719,7 +725,11 @@ async def _async_step_choose(self) -> None: await self._async_run_script(script) return except exceptions.ConditionError as ex: - _LOGGER.warning("Error in 'choose' evaluation:\n%s", ex) + self._log( + "Error in 'choose' evaluation:\n%s", + ex, + level=logging.WARNING, + ) if choose_data["default"] is not None: trace_set_result(choice="default") @@ -738,7 +748,7 @@ async def _async_step_condition(self) -> None: trace_element.reuse_by_child = True check = cond(self._hass, self._variables) except exceptions.ConditionError as ex: - _LOGGER.warning("Error in 'condition' evaluation:\n%s", ex) + self._log("Error in 'condition' evaluation:\n%s", ex, level=logging.WARNING) check = False self._log("Test condition %s: %s", self._script.last_action, check) @@ -751,13 +761,10 @@ async def _async_step_if(self) -> None: if_data = await self._script._async_get_if_data(self._step) # noqa: SLF001 test_conditions: bool | None = False - try: - with trace_path("if"): - test_conditions = self._test_conditions( - if_data["if_conditions"], "if", "condition" - ) - except exceptions.ConditionError as ex: - _LOGGER.warning("Error in 'if' evaluation:\n%s", ex) + with trace_path("if"): + test_conditions = self._test_conditions( + if_data["if_conditions"], "if", "condition" + ) if test_conditions: trace_set_result(choice="then") @@ -848,33 +855,28 @@ async def async_run_sequence(iteration: int, extra_msg: str = "") -> None: ] for iteration in itertools.count(1): set_repeat_var(iteration) - try: - if self._stop.done(): - break - if not self._test_conditions(conditions, "while"): - break - except exceptions.ConditionError as ex: - _LOGGER.warning("Error in 'while' evaluation:\n%s", ex) + if self._stop.done(): + break + if not self._test_conditions(conditions, "while"): break if iteration > 1: if iteration > REPEAT_WARN_ITERATIONS: if not warned_too_many_loops: warned_too_many_loops = True - _LOGGER.warning( - "While condition %s in script `%s` looped %s times", + self._log( + "While condition %s looped %s times", repeat[CONF_WHILE], - self._script.name, REPEAT_WARN_ITERATIONS, + level=logging.WARNING, ) if iteration > REPEAT_TERMINATE_ITERATIONS: - _LOGGER.critical( - "While condition %s in script `%s` " - "terminated because it looped %s times", + self._log( + "While condition %s terminated because it looped %s times", repeat[CONF_WHILE], - self._script.name, REPEAT_TERMINATE_ITERATIONS, + level=logging.CRITICAL, ) raise _AbortScript( f"While condition {repeat[CONF_WHILE]} " @@ -896,32 +898,27 @@ async def async_run_sequence(iteration: int, extra_msg: str = "") -> None: for iteration in itertools.count(1): set_repeat_var(iteration) await async_run_sequence(iteration) - try: - if self._stop.done(): - break - if self._test_conditions(conditions, "until") in [True, None]: - break - except exceptions.ConditionError as ex: - _LOGGER.warning("Error in 'until' evaluation:\n%s", ex) + if self._stop.done(): + break + if self._test_conditions(conditions, "until") in [True, None]: break if iteration >= REPEAT_WARN_ITERATIONS: if not warned_too_many_loops: warned_too_many_loops = True - _LOGGER.warning( - "Until condition %s in script `%s` looped %s times", + self._log( + "Until condition %s looped %s times", repeat[CONF_UNTIL], - self._script.name, REPEAT_WARN_ITERATIONS, + level=logging.WARNING, ) if iteration >= REPEAT_TERMINATE_ITERATIONS: - _LOGGER.critical( - "Until condition %s in script `%s` " - "terminated because it looped %s times", + self._log( + "Until condition %s terminated because it looped %s times", repeat[CONF_UNTIL], - self._script.name, REPEAT_TERMINATE_ITERATIONS, + level=logging.CRITICAL, ) raise _AbortScript( f"Until condition {repeat[CONF_UNTIL]} " diff --git a/requirements_all.txt b/requirements_all.txt index 18b23b89933cad..1454fe2fa67b9c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1195,7 +1195,7 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.82 +holidays==0.83 # homeassistant.components.frontend home-assistant-frontend==20251001.4 @@ -1981,7 +1981,7 @@ pydrawise==2025.9.0 pydroid-ipcam==3.0.0 # homeassistant.components.droplet -pydroplet==2.3.3 +pydroplet==2.3.4 # homeassistant.components.ebox pyebox==1.1.4 @@ -2305,7 +2305,7 @@ pyplaato==0.0.19 pypoint==3.0.0 # homeassistant.components.portainer -pyportainer==1.0.4 +pyportainer==1.0.7 # homeassistant.components.probe_plus pyprobeplus==1.1.2 @@ -3045,7 +3045,7 @@ triggercmd==0.0.36 ttls==1.8.3 # homeassistant.components.thethingsnetwork -ttn_client==1.2.0 +ttn_client==1.2.2 # homeassistant.components.tuya tuya-device-sharing-sdk==0.2.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cc206d4ed73908..a00d3672ae49b1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1044,7 +1044,7 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.82 +holidays==0.83 # homeassistant.components.frontend home-assistant-frontend==20251001.4 @@ -1665,7 +1665,7 @@ pydrawise==2025.9.0 pydroid-ipcam==3.0.0 # homeassistant.components.droplet -pydroplet==2.3.3 +pydroplet==2.3.4 # homeassistant.components.ecoforest pyecoforest==0.4.0 @@ -1932,7 +1932,7 @@ pyplaato==0.0.19 pypoint==3.0.0 # homeassistant.components.portainer -pyportainer==1.0.4 +pyportainer==1.0.7 # homeassistant.components.probe_plus pyprobeplus==1.1.2 @@ -2522,7 +2522,7 @@ triggercmd==0.0.36 ttls==1.8.3 # homeassistant.components.thethingsnetwork -ttn_client==1.2.0 +ttn_client==1.2.2 # homeassistant.components.tuya tuya-device-sharing-sdk==0.2.4 diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index d476ea5da4444b..05b08b5912390b 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -317,7 +317,25 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: translation_value_validator, slug_validator=translation_key_validator, ), - vol.Optional("fields"): cv.schema_with_slug_keys(str), + vol.Optional("fields"): vol.Any( + # Old format: + # "key": "translation" + cv.schema_with_slug_keys(str), + # New format: + # "key": { + # "name": "translated field name", + # "description": "translated field description" + # } + cv.schema_with_slug_keys( + { + vol.Required("name"): str, + vol.Required( + "description" + ): translation_value_validator, + }, + slug_validator=translation_key_validator, + ), + ), }, slug_validator=vol.Any("_", cv.slug), ), diff --git a/tests/components/control4/conftest.py b/tests/components/control4/conftest.py index f8e174b9d95794..c6b54fb8373ce5 100644 --- a/tests/components/control4/conftest.py +++ b/tests/components/control4/conftest.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components.control4.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from tests.common import MockConfigEntry, load_fixture @@ -21,6 +21,7 @@ def mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" return MockConfigEntry( domain=DOMAIN, + title="Test Controller", data={ CONF_HOST: MOCK_HOST, CONF_USERNAME: MOCK_USERNAME, @@ -54,7 +55,7 @@ def mock_c4_director() -> Generator[MagicMock]: "homeassistant.components.control4.C4Director", autospec=True ) as mock_director_class: mock_director = mock_director_class.return_value - # Default: Multi-room setup (room with sources, room without sources) + # Multi-platform setup: media room, climate room, shared devices # Note: The API returns JSON strings, so we load fixtures as strings mock_director.getAllItemInfo = AsyncMock( return_value=load_fixture("director_all_items.json", DOMAIN) @@ -91,13 +92,57 @@ async def _mock_update_variables(*args, **kwargs): @pytest.fixture -def platforms() -> list[str]: +def mock_climate_variables() -> dict: + """Mock climate variable data for default thermostat state.""" + return { + 123: { + "HVAC_STATE": "idle", + "HVAC_MODE": "Heat", + "TEMPERATURE_F": 72.5, + "HUMIDITY": 45, + "COOL_SETPOINT_F": 75.0, + "HEAT_SETPOINT_F": 68.0, + } + } + + +@pytest.fixture +def mock_climate_update_variables( + mock_climate_variables: dict, +) -> Generator[AsyncMock]: + """Mock update_variables for climate platform.""" + + async def _mock_update_variables(*args, **kwargs): + return mock_climate_variables + + with patch( + "homeassistant.components.control4.climate.update_variables_for_config_entry", + new=_mock_update_variables, + ) as mock_update: + yield mock_update + + +@pytest.fixture +def mock_c4_climate() -> Generator[MagicMock]: + """Mock C4Climate class.""" + with patch( + "homeassistant.components.control4.climate.C4Climate", autospec=True + ) as mock_class: + mock_instance = mock_class.return_value + mock_instance.setHvacMode = AsyncMock() + mock_instance.setHeatSetpointF = AsyncMock() + mock_instance.setCoolSetpointF = AsyncMock() + yield mock_instance + + +@pytest.fixture +def platforms() -> list[Platform]: """Platforms which should be loaded during the test.""" - return ["media_player"] + return [Platform.MEDIA_PLAYER] @pytest.fixture(autouse=True) -async def mock_patch_platforms(platforms: list[str]) -> AsyncGenerator[None]: +async def mock_patch_platforms(platforms: list[Platform]) -> AsyncGenerator[None]: """Fixture to set up platforms for tests.""" with patch("homeassistant.components.control4.PLATFORMS", platforms): yield diff --git a/tests/components/control4/fixtures/director_all_items.json b/tests/components/control4/fixtures/director_all_items.json index 40e44c1178b905..68d1010fd3e935 100644 --- a/tests/components/control4/fixtures/director_all_items.json +++ b/tests/components/control4/fixtures/director_all_items.json @@ -14,5 +14,18 @@ { "id": 100, "name": "TV" + }, + { + "id": 123, + "name": "Residential Thermostat V2", + "type": 7, + "parentId": 456, + "categories": ["comfort"] + }, + { + "id": 456, + "manufacturer": "Control4", + "roomName": "Studio", + "model": "C4-TSTAT" } ] diff --git a/tests/components/control4/snapshots/test_climate.ambr b/tests/components/control4/snapshots/test_climate.ambr new file mode 100644 index 00000000000000..d961babf59f5ba --- /dev/null +++ b/tests/components/control4/snapshots/test_climate.ambr @@ -0,0 +1,73 @@ +# serializer version: 1 +# name: test_climate_entities[climate.test_controller_residential_thermostat_v2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 95, + 'min_temp': 45, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_controller_residential_thermostat_v2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Residential Thermostat V2', + 'platform': 'control4', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '123', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entities[climate.test_controller_residential_thermostat_v2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 45, + 'current_temperature': 72, + 'friendly_name': 'Test Controller Residential Thermostat V2', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 95, + 'min_temp': 45, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 68, + }), + 'context': , + 'entity_id': 'climate.test_controller_residential_thermostat_v2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/control4/test_climate.py b/tests/components/control4/test_climate.py new file mode 100644 index 00000000000000..cba5c235adc624 --- /dev/null +++ b/tests/components/control4/test_climate.py @@ -0,0 +1,390 @@ +"""Test Control4 Climate.""" + +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + HVACAction, + HVACMode, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_ID = "climate.test_controller_residential_thermostat_v2" + + +@pytest.fixture +def platforms() -> list[Platform]: + """Platforms which should be loaded during the test.""" + return [Platform.CLIMATE] + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> MockConfigEntry: + """Set up the Control4 integration for testing.""" + hass.config.units = US_CUSTOMARY_SYSTEM + await setup_integration(hass, mock_config_entry) + return mock_config_entry + + +@pytest.mark.usefixtures( + "mock_c4_account", + "mock_c4_director", + "mock_climate_update_variables", + "init_integration", +) +async def test_climate_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test climate entities are set up correctly with proper attributes.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ( + "mock_climate_variables", + "expected_hvac_mode", + "expected_hvac_action", + "expected_temperature", + "expected_temp_high", + "expected_temp_low", + ), + [ + pytest.param( + { + 123: { + "HVAC_STATE": "off", + "HVAC_MODE": "Off", + "TEMPERATURE_F": 72.0, + "HUMIDITY": 50, + "COOL_SETPOINT_F": 75.0, + "HEAT_SETPOINT_F": 68.0, + } + }, + HVACMode.OFF, + HVACAction.OFF, + None, + None, + None, + id="off", + ), + pytest.param( + { + 123: { + "HVAC_STATE": "cooling", + "HVAC_MODE": "Cool", + "TEMPERATURE_F": 74.0, + "HUMIDITY": 55, + "COOL_SETPOINT_F": 72.0, + "HEAT_SETPOINT_F": 68.0, + } + }, + HVACMode.COOL, + HVACAction.COOLING, + 72.0, + None, + None, + id="cool", + ), + pytest.param( + { + 123: { + "HVAC_STATE": "heating", + "HVAC_MODE": "Auto", + "TEMPERATURE_F": 65.0, + "HUMIDITY": 40, + "COOL_SETPOINT_F": 75.0, + "HEAT_SETPOINT_F": 68.0, + } + }, + HVACMode.HEAT_COOL, + HVACAction.HEATING, + None, + 75.0, + 68.0, + id="auto", + ), + ], +) +@pytest.mark.usefixtures( + "mock_c4_account", + "mock_c4_director", + "mock_climate_update_variables", + "init_integration", +) +async def test_climate_states( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + expected_hvac_mode: HVACMode, + expected_hvac_action: HVACAction, + expected_temperature: float | None, + expected_temp_high: float | None, + expected_temp_low: float | None, +) -> None: + """Test climate entity in different states.""" + state = hass.states.get(ENTITY_ID) + assert state.state == expected_hvac_mode + assert state.attributes["hvac_action"] == expected_hvac_action + + assert state.attributes.get("temperature") == expected_temperature + + assert state.attributes.get("target_temp_high") == expected_temp_high + assert state.attributes.get("target_temp_low") == expected_temp_low + + +@pytest.mark.parametrize( + ("hvac_mode", "expected_c4_mode"), + [ + pytest.param(HVACMode.COOL, "Cool", id="cool"), + pytest.param(HVACMode.OFF, "Off", id="off"), + ], +) +@pytest.mark.usefixtures( + "mock_c4_account", + "mock_c4_director", + "mock_climate_update_variables", + "init_integration", +) +async def test_set_hvac_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_c4_climate: MagicMock, + hvac_mode: HVACMode, + expected_c4_mode: str, +) -> None: + """Test setting HVAC mode.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: hvac_mode}, + blocking=True, + ) + mock_c4_climate.setHvacMode.assert_called_once_with(expected_c4_mode) + + +@pytest.mark.parametrize( + ("mock_climate_variables", "method_name"), + [ + pytest.param( + { + 123: { + "HVAC_STATE": "idle", + "HVAC_MODE": "Heat", + "TEMPERATURE_F": 72.5, + "HUMIDITY": 45, + "COOL_SETPOINT_F": 75.0, + "HEAT_SETPOINT_F": 68.0, + } + }, + "setHeatSetpointF", + id="heat", + ), + pytest.param( + { + 123: { + "HVAC_STATE": "idle", + "HVAC_MODE": "Cool", + "TEMPERATURE_F": 74.0, + "HUMIDITY": 50, + "COOL_SETPOINT_F": 72.0, + "HEAT_SETPOINT_F": 68.0, + } + }, + "setCoolSetpointF", + id="cool", + ), + ], +) +@pytest.mark.usefixtures( + "mock_c4_account", + "mock_c4_director", + "mock_climate_update_variables", + "init_integration", +) +async def test_set_temperature( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_c4_climate: MagicMock, + method_name: str, +) -> None: + """Test setting temperature in different modes.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 70.0}, + blocking=True, + ) + getattr(mock_c4_climate, method_name).assert_called_once_with(70.0) + + +@pytest.mark.parametrize( + "mock_climate_variables", + [ + { + 123: { + "HVAC_STATE": "idle", + "HVAC_MODE": "Auto", + "TEMPERATURE_F": 70.0, + "HUMIDITY": 50, + "COOL_SETPOINT_F": 75.0, + "HEAT_SETPOINT_F": 68.0, + } + } + ], +) +@pytest.mark.usefixtures( + "mock_c4_account", + "mock_c4_director", + "mock_climate_update_variables", + "init_integration", +) +async def test_set_temperature_range_auto_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_c4_climate: MagicMock, +) -> None: + """Test setting temperature range in auto mode.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_TARGET_TEMP_LOW: 65.0, + ATTR_TARGET_TEMP_HIGH: 78.0, + }, + blocking=True, + ) + mock_c4_climate.setHeatSetpointF.assert_called_once_with(65.0) + mock_c4_climate.setCoolSetpointF.assert_called_once_with(78.0) + + +@pytest.mark.parametrize("mock_climate_variables", [{}]) +@pytest.mark.usefixtures( + "mock_c4_account", + "mock_c4_director", + "mock_climate_update_variables", + "init_integration", +) +async def test_climate_not_created_when_no_initial_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test climate entity is not created when coordinator has no initial data.""" + # Entity should not be created if there's no data during initial setup + state = hass.states.get(ENTITY_ID) + assert state is None + + +@pytest.mark.parametrize( + "mock_climate_variables", + [ + { + 123: { + "HVAC_STATE": "idle", + "HVAC_MODE": "Heat", + # Missing TEMPERATURE_F and HUMIDITY + "COOL_SETPOINT_F": 75.0, + "HEAT_SETPOINT_F": 68.0, + } + } + ], +) +@pytest.mark.usefixtures( + "mock_c4_account", + "mock_c4_director", + "mock_climate_update_variables", + "init_integration", +) +async def test_climate_missing_variables( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test climate entity handles missing variables gracefully.""" + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == HVACMode.HEAT + assert state.attributes.get("current_temperature") is None + assert state.attributes.get("current_humidity") is None + assert state.attributes["temperature"] == 68.0 + + +@pytest.mark.parametrize( + "mock_climate_variables", + [ + { + 123: { + "HVAC_STATE": "idle", + "HVAC_MODE": "UnknownMode", + "TEMPERATURE_F": 72.0, + "HUMIDITY": 50, + "COOL_SETPOINT_F": 75.0, + "HEAT_SETPOINT_F": 68.0, + } + } + ], +) +@pytest.mark.usefixtures( + "mock_c4_account", + "mock_c4_director", + "mock_climate_update_variables", + "init_integration", +) +async def test_climate_unknown_hvac_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test climate entity handles unknown HVAC mode.""" + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == HVACMode.OFF # Defaults to OFF for unknown modes + + +@pytest.mark.parametrize( + "mock_climate_variables", + [ + { + 123: { + "HVAC_STATE": "unknown_state", + "HVAC_MODE": "Heat", + "TEMPERATURE_F": 72.0, + "HUMIDITY": 50, + "COOL_SETPOINT_F": 75.0, + "HEAT_SETPOINT_F": 68.0, + } + } + ], +) +@pytest.mark.usefixtures( + "mock_c4_account", + "mock_c4_director", + "mock_climate_update_variables", + "init_integration", +) +async def test_climate_unknown_hvac_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test climate entity handles unknown HVAC state.""" + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes.get("hvac_action") is None diff --git a/tests/components/control4/test_media_player.py b/tests/components/control4/test_media_player.py index 8b08a9ee65f4b4..28b94e84909617 100644 --- a/tests/components/control4/test_media_player.py +++ b/tests/components/control4/test_media_player.py @@ -3,6 +3,7 @@ import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -11,6 +12,12 @@ from tests.common import MockConfigEntry, snapshot_platform +@pytest.fixture +def platforms() -> list[Platform]: + """Platforms which should be loaded during the test.""" + return [Platform.MEDIA_PLAYER] + + @pytest.mark.usefixtures("mock_c4_account", "mock_c4_director", "mock_update_variables") async def test_media_player_with_and_without_sources( hass: HomeAssistant, diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py index 4234aab40c1f8c..8f15959234a9d0 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_repairs.py @@ -994,3 +994,92 @@ async def test_supervisor_issue_addon_boot_fail( assert not issue_registry.async_get_issue(domain="hassio", issue_id=issue_uuid.hex) supervisor_client.resolution.apply_suggestion.assert_called_once_with(sugg_uuid) + + +@pytest.mark.parametrize( + "all_setup_requests", [{"include_addons": True}], indirect=True +) +@pytest.mark.usefixtures("all_setup_requests") +async def test_supervisor_issue_deprecated_addon( + hass: HomeAssistant, + supervisor_client: AsyncMock, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, +) -> None: + """Test fix flow for supervisor issue for deprecated add-on.""" + mock_resolution_info( + supervisor_client, + issues=[ + Issue( + type=IssueType.DEPRECATED_ADDON, + context=ContextType.ADDON, + reference="test", + uuid=(issue_uuid := uuid4()), + ), + ], + suggestions_by_issue={ + issue_uuid: [ + Suggestion( + type=SuggestionType.EXECUTE_REMOVE, + context=ContextType.ADDON, + reference="test", + uuid=(sugg_uuid := uuid4()), + auto=False, + ), + ] + }, + ) + + assert await async_setup_component(hass, "hassio", {}) + + repair_issue = issue_registry.async_get_issue( + domain="hassio", issue_id=issue_uuid.hex + ) + assert repair_issue + + client = await hass_client() + + resp = await client.post( + "/api/repairs/issues/fix", + json={"handler": "hassio", "issue_id": repair_issue.issue_id}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "type": "form", + "flow_id": flow_id, + "handler": "hassio", + "step_id": "addon_execute_remove", + "data_schema": [], + "errors": None, + "description_placeholders": { + "reference": "test", + "addon": "test", + "help_url": "https://www.home-assistant.io/help/", + "community_url": "https://community.home-assistant.io/", + "addon_info": "homeassistant://hassio/addon/test/info", + "addon_documentation": "homeassistant://hassio/addon/test/documentation", + }, + "last_step": True, + "preview": None, + } + + resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "type": "create_entry", + "flow_id": flow_id, + "handler": "hassio", + "description": None, + "description_placeholders": None, + } + + assert not issue_registry.async_get_issue(domain="hassio", issue_id=issue_uuid.hex) + supervisor_client.resolution.apply_suggestion.assert_called_once_with(sugg_uuid) diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index e2df2597345476..b86e49fdb5557b 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -86,6 +86,7 @@ async def integration_fixture( "dimmable_plugin_unit", "door_lock", "door_lock_with_unbolt", + "eberle_ute3000", "eve_contact_sensor", "eve_energy_20ecn4101", "eve_energy_plug", diff --git a/tests/components/matter/fixtures/nodes/eberle_ute3000.json b/tests/components/matter/fixtures/nodes/eberle_ute3000.json new file mode 100644 index 00000000000000..c8fc4390d3e982 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/eberle_ute3000.json @@ -0,0 +1,256 @@ +{ + "node_id": 20, + "date_commissioned": "2025-02-05T17:31:08.969773", + "last_interview": "2025-10-17T07:43:55.215739", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/65533": 1, + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 54, 42], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [65528, 65529, 65531, 65533, 0, 1, 2, 3, 65532], + "0/31/65533": 1, + "0/31/0": [ + { + "254": 1 + }, + { + "254": 1 + }, + { + "254": 2 + }, + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 + } + ], + "0/31/2": 4, + "0/31/4": 4, + "0/31/3": 3, + "0/31/65532": 0, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [65528, 65529, 65531, 65533, 0, 2, 4, 3, 65532], + "0/40/65532": 0, + "0/40/0": 1, + "0/40/6": "**REDACTED**", + "0/40/1": "EBERLE Controls GmbH", + "0/40/2": 5219, + "0/40/3": "Connected Thermostat UTE 3000", + "0/40/4": 1, + "0/40/7": 1, + "0/40/8": "V1.0", + "0/40/9": 285249536, + "0/40/10": "1.7.144.0", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/65533": 1, + "0/40/5": "\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000", + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 65528, 65529, 65531, 65532, 0, 6, 1, 2, 3, 4, 7, 8, 9, 10, 19, 65533, 5 + ], + "0/48/65532": 0, + "0/48/2": 0, + "0/48/3": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/4": true, + "0/48/65533": 1, + "0/48/0": 0, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [65528, 65529, 65531, 65532, 2, 3, 1, 4, 65533, 0], + "0/49/0": 1, + "0/49/1": [ + { + "0": "d2VAaG9tZQ==", + "1": true + } + ], + "0/49/2": 10, + "0/49/3": 30, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "d2VAaG9tZQ==", + "0/49/7": null, + "0/49/65532": 1, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 2, 4, 6, 8], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "SMpDMzDc", + "5": ["Cv+yOw=="], + "6": ["/oAAAAAAAABKykP//jMw3A==", "/ZD8ujbsB0FKykP//jMw3A=="], + "7": 1 + } + ], + "0/51/1": 14, + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [0, 1, 8, 65528, 65529, 65531, 65532, 65533], + "0/60/65532": 0, + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [65528, 65529, 65531, 65532, 0, 1, 2, 65533], + "0/62/65532": 0, + "0/62/0": [ + { + "254": 1 + }, + { + "254": 2 + }, + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRFBgkBwEkCAEwCUEEZ0w6mkHTNp5p8Mpdwim7pMvWvikSM3Pj61NFSlE4TE14lFtWM4gjPjgStLoIPZDu931yJ3ixXVmRbSKoUKX+FzcKNQEoARgkAgE2AwQCBAEYMAQUFOFJpjKve6wb7QWGHHBNr56tq5UwBRSDAxnt9OVh4D7HavQAl5go/ax2tBgwC0B1NgwIKX3mcyJQ4Xp7Sn/OMiTlsQe2UO4ioJH2os6PE8s3NbyJNoiGZVI1LQZpsw0MFiKlvSRus6xgLUPN8WmIGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEVfW9YXAmV8MUOVR/Uoktjm5n2OjRn7V4/6h/FyHERageygyLGrb/4t0mSnk3wDZ0ihC5tumk6gbt50RL77SOizcKNQEpARgkAmAwBBSDAxnt9OVh4D7HavQAl5go/ax2tDAFFFSZpoqxN0SLhYbAXDO9h1Lo0H3nGDALQKONIw1GhtI7k3QavE/9lrxkrqzLzbqSc51k44gvyvRdm6o8bKUoXd14vwHtzh4X/RJHZDElgz/N/2O417GwxygY", + "254": 3 + } + ], + "0/62/2": 5, + "0/62/3": 3, + "0/62/1": [ + { + "1": "BNK8FRPwvuy0k3dIHa1LdOWXrYXlIuARXN9DVYk/g+wKuIz2X5DiTDz8NCqyMGYUMZfOzSN2XrZG7oPHiK34ICk=", + "2": 4937, + "3": 1574089007, + "4": 957385991, + "5": "RaMa", + "254": 1 + }, + { + "1": "BG2Z0+Kyh70D+fr3TMQQhYzRSvsqhWaimpvCrBW/o/aalEks4kOePHQ4A/zfiKPXV84o6VrFUeN2e6sU+tNaPio=", + "2": 4996, + "3": 1, + "4": 493504042, + "5": "", + "254": 2 + }, + { + "1": "BO13DF1A/bQsFvEHaeuaj+31c2NPA5GC/lARAwKOYvY8GNfPNWtiLcbCeJXrfLFCnaP8u87aNap8DobsrKm5Dfk=", + "2": 4939, + "3": 2, + "4": 20, + "5": "Home", + "254": 3 + } + ], + "0/62/4": [ + "FTABAQAkAgE3AyYUCd83BCYVL7HSXRgmBAAcNywkBQA3BiYUCd83BCYVL7HSXRgkBwEkCAEwCUEE0rwVE/C+7LSTd0gdrUt05ZetheUi4BFc30NViT+D7Aq4jPZfkOJMPPw0KrIwZhQxl87NI3Zetkbug8eIrfggKTcKNQEpARgkAmAwBBSXr/Rhe4RZjjaFYDFs53d/5Hm2GjAFFJev9GF7hFmONoVgMWznd3/kebYaGDALQCUHVuqvkTH1NCq74knaVrISzAMjXIbjZbCJnF0zIga8xl/RQdaNiT++0xMi+vD2Tck0A5PsI3O/vuTWUbsuvXAY", + "FTABAQAkAgE3AycU1cDXQ1a4Ma0kFQEYJgQeHDcsJgWeTxguNwYnFNXA10NWuDGtJBUBGCQHASQIATAJQQRtmdPisoe9A/n690zEEIWM0Ur7KoVmopqbwqwVv6P2mpRJLOJDnjx0OAP834ij11fOKOlaxVHjdnurFPrTWj4qNwo1ASkBGCQCYDAEFGyyLPv0Vs7zwInjquHu34d2ZHI/MAUUbLIs+/RWzvPAieOq4e7fh3Zkcj8YMAtA8E0wSRMy64ICKLvdwX2egenx4KNHM3j77Rn/RcOQ+PaT6zVtRjA1bN+gTLC+z84TdnF7+xbDzwJ5CocwBDNGRxg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEE7XcMXUD9tCwW8Qdp65qP7fVzY08DkYL+UBEDAo5i9jwY1881a2ItxsJ4let8sUKdo/y7zto1qnwOhuysqbkN+TcKNQEpARgkAmAwBBRUmaaKsTdEi4WGwFwzvYdS6NB95zAFFFSZpoqxN0SLhYbAXDO9h1Lo0H3nGDALQGN23+E4U4tLfRJJpLTC1k6IG4fkt0mXMocO9kPQkmTAOkerpFFYpTL8bvqUtCErTEliePLL2ZayUDoWJoslKTUY" + ], + "0/62/5": 3, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [65528, 65529, 65531, 65532, 0, 2, 3, 1, 4, 5, 65533], + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [65528, 65529, 65531, 65532, 65533, 0, 1, 2, 3], + "0/54/65532": 0, + "0/54/0": "PDcSRuzP", + "0/54/1": 4, + "0/54/2": 3, + "0/54/3": 11, + "0/54/4": -53, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [], + "0/54/65531": [65528, 65529, 65531, 65532, 0, 1, 2, 3, 4, 65533], + "0/42/65532": 0, + "0/42/0": [ + { + "1": 3512032450, + "2": 0, + "254": 1 + } + ], + "0/42/65533": 1, + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [65528, 65529, 65531, 65532, 0, 65533, 1, 2, 3], + "1/29/65533": 1, + "1/29/0": [ + { + "0": 769, + "1": 2 + } + ], + "1/29/1": [29, 3, 513, 4], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [65528, 65529, 65531, 65533, 0, 1, 2, 3, 65532], + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/0": 0, + "1/3/1": 2, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [65528, 65529, 65531, 65532, 65533, 0, 1], + "1/513/65532": 1, + "1/513/65533": 6, + "1/513/0": 2250, + "1/513/27": 2, + "1/513/28": 4, + "1/513/3": 500, + "1/513/4": 3000, + "1/513/8": 100, + "1/513/18": 2250, + "1/513/65528": [], + "1/513/65529": [0], + "1/513/65531": [65528, 65529, 65531, 65532, 65533, 0, 27, 28, 3, 4, 8, 18], + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/0": 128, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65531": [65528, 65529, 65531, 65532, 65533, 0] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/snapshots/test_button.ambr b/tests/components/matter/snapshots/test_button.ambr index 98b552d615fa69..0c5b81ba644b2b 100644 --- a/tests/components/matter/snapshots/test_button.ambr +++ b/tests/components/matter/snapshots/test_button.ambr @@ -389,6 +389,55 @@ 'state': 'unknown', }) # --- +# name: test_buttons[eberle_ute3000][button.connected_thermostat_ute_3000_identify-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.connected_thermostat_ute_3000_identify', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Identify', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000014-MatterNodeDevice-1-IdentifyButton-3-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[eberle_ute3000][button.connected_thermostat_ute_3000_identify-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'identify', + 'friendly_name': 'Connected Thermostat UTE 3000 Identify', + }), + 'context': , + 'entity_id': 'button.connected_thermostat_ute_3000_identify', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[eve_contact_sensor][button.eve_door_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_climate.ambr b/tests/components/matter/snapshots/test_climate.ambr index f0745bfe50ca6a..92a3181b40f036 100644 --- a/tests/components/matter/snapshots/test_climate.ambr +++ b/tests/components/matter/snapshots/test_climate.ambr @@ -63,6 +63,70 @@ 'state': 'off', }) # --- +# name: test_climates[eberle_ute3000][climate.connected_thermostat_ute_3000-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.connected_thermostat_ute_3000', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000014-MatterNodeDevice-1-MatterThermostat-513-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_climates[eberle_ute3000][climate.connected_thermostat_ute_3000-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 22.5, + 'friendly_name': 'Connected Thermostat UTE 3000', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + 'supported_features': , + 'temperature': 22.5, + }), + 'context': , + 'entity_id': 'climate.connected_thermostat_ute_3000', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- # name: test_climates[eve_thermo][climate.eve_thermo-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 94b7d7c68c04b6..4b20cab2ac58eb 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -2323,6 +2323,111 @@ 'state': '180.0', }) # --- +# name: test_sensors[eberle_ute3000][sensor.connected_thermostat_ute_3000_heating_demand-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.connected_thermostat_ute_3000_heating_demand', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heating demand', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pi_heating_demand', + 'unique_id': '00000000000004D2-0000000000000014-MatterNodeDevice-1-ThermostatPIHeatingDemand-513-8', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[eberle_ute3000][sensor.connected_thermostat_ute_3000_heating_demand-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Connected Thermostat UTE 3000 Heating demand', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.connected_thermostat_ute_3000_heating_demand', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensors[eberle_ute3000][sensor.connected_thermostat_ute_3000_temperature-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': None, + 'entity_id': 'sensor.connected_thermostat_ute_3000_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000014-MatterNodeDevice-1-ThermostatLocalTemperature-513-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[eberle_ute3000][sensor.connected_thermostat_ute_3000_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Connected Thermostat UTE 3000 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.connected_thermostat_ute_3000_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.5', + }) +# --- # name: test_sensors[eve_contact_sensor][sensor.eve_door_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5013,6 +5118,68 @@ 'state': 'pre-soak', }) # --- +# name: test_sensors[laundry_dryer][sensor.mock_laundrydryer_operational_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_error', + 'unable_to_start_or_resume', + 'unable_to_complete_operation', + 'command_invalid_in_state', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_laundrydryer_operational_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Operational error', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'operational_error', + 'unique_id': '00000000000004D2-0000000000000008-MatterNodeDevice-1-OperationalStateOperationalError-96-5', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[laundry_dryer][sensor.mock_laundrydryer_operational_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock Laundrydryer Operational error', + 'options': list([ + 'no_error', + 'unable_to_start_or_resume', + 'unable_to_complete_operation', + 'command_invalid_in_state', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_laundrydryer_operational_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_error', + }) +# --- # name: test_sensors[laundry_dryer][sensor.mock_laundrydryer_operational_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5177,6 +5344,68 @@ 'state': '2025-01-01T14:00:30+00:00', }) # --- +# name: test_sensors[microwave_oven][sensor.microwave_oven_operational_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_error', + 'unable_to_start_or_resume', + 'unable_to_complete_operation', + 'command_invalid_in_state', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.microwave_oven_operational_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Operational error', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'operational_error', + 'unique_id': '00000000000004D2-000000000000009D-MatterNodeDevice-1-OperationalStateOperationalError-96-5', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[microwave_oven][sensor.microwave_oven_operational_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Microwave Oven Operational error', + 'options': list([ + 'no_error', + 'unable_to_start_or_resume', + 'unable_to_complete_operation', + 'command_invalid_in_state', + ]), + }), + 'context': , + 'entity_id': 'sensor.microwave_oven_operational_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_error', + }) +# --- # name: test_sensors[microwave_oven][sensor.microwave_oven_operational_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -6195,6 +6424,68 @@ 'state': '0.0', }) # --- +# name: test_sensors[silabs_dishwasher][sensor.dishwasher_operational_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_error', + 'unable_to_start_or_resume', + 'unable_to_complete_operation', + 'command_invalid_in_state', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dishwasher_operational_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Operational error', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'operational_error', + 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-1-OperationalStateOperationalError-96-5', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[silabs_dishwasher][sensor.dishwasher_operational_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Dishwasher Operational error', + 'options': list([ + 'no_error', + 'unable_to_start_or_resume', + 'unable_to_complete_operation', + 'command_invalid_in_state', + ]), + }), + 'context': , + 'entity_id': 'sensor.dishwasher_operational_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_error', + }) +# --- # name: test_sensors[silabs_dishwasher][sensor.dishwasher_operational_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -7056,6 +7347,68 @@ 'state': '0.0', }) # --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_operational_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_error', + 'unable_to_start_or_resume', + 'unable_to_complete_operation', + 'command_invalid_in_state', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.laundrywasher_operational_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Operational error', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'operational_error', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-OperationalStateOperationalError-96-5', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_operational_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'LaundryWasher Operational error', + 'options': list([ + 'no_error', + 'unable_to_start_or_resume', + 'unable_to_complete_operation', + 'command_invalid_in_state', + ]), + }), + 'context': , + 'entity_id': 'sensor.laundrywasher_operational_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_error', + }) +# --- # name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_operational_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -8094,6 +8447,98 @@ 'state': '234.899', }) # --- +# name: test_sensors[switchbot_k11_plus][sensor.k11_operational_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_error', + 'unable_to_start_or_resume', + 'unable_to_complete_operation', + 'command_invalid_in_state', + 'failed_to_find_charging_dock', + 'stuck', + 'dust_bin_missing', + 'dust_bin_full', + 'water_tank_empty', + 'water_tank_missing', + 'water_tank_lid_open', + 'mop_cleaning_pad_missing', + 'low_battery', + 'cannot_reach_target_area', + 'dirty_water_tank_full', + 'dirty_water_tank_missing', + 'wheels_jammed', + 'brush_jammed', + 'navigation_sensor_obscured', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.k11_operational_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Operational error', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'operational_error', + 'unique_id': '00000000000004D2-0000000000000061-MatterNodeDevice-1-RvcOperationalStateOperationalError-97-5', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[switchbot_k11_plus][sensor.k11_operational_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'K11+ Operational error', + 'options': list([ + 'no_error', + 'unable_to_start_or_resume', + 'unable_to_complete_operation', + 'command_invalid_in_state', + 'failed_to_find_charging_dock', + 'stuck', + 'dust_bin_missing', + 'dust_bin_full', + 'water_tank_empty', + 'water_tank_missing', + 'water_tank_lid_open', + 'mop_cleaning_pad_missing', + 'low_battery', + 'cannot_reach_target_area', + 'dirty_water_tank_full', + 'dirty_water_tank_missing', + 'wheels_jammed', + 'brush_jammed', + 'navigation_sensor_obscured', + ]), + }), + 'context': , + 'entity_id': 'sensor.k11_operational_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_error', + }) +# --- # name: test_sensors[switchbot_k11_plus][sensor.k11_operational_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -8488,6 +8933,98 @@ 'state': '2025-08-29T21:00:00+00:00', }) # --- +# name: test_sensors[vacuum_cleaner][sensor.mock_vacuum_operational_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_error', + 'unable_to_start_or_resume', + 'unable_to_complete_operation', + 'command_invalid_in_state', + 'failed_to_find_charging_dock', + 'stuck', + 'dust_bin_missing', + 'dust_bin_full', + 'water_tank_empty', + 'water_tank_missing', + 'water_tank_lid_open', + 'mop_cleaning_pad_missing', + 'low_battery', + 'cannot_reach_target_area', + 'dirty_water_tank_full', + 'dirty_water_tank_missing', + 'wheels_jammed', + 'brush_jammed', + 'navigation_sensor_obscured', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_vacuum_operational_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Operational error', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'operational_error', + 'unique_id': '00000000000004D2-0000000000000042-MatterNodeDevice-1-RvcOperationalStateOperationalError-97-5', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[vacuum_cleaner][sensor.mock_vacuum_operational_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock Vacuum Operational error', + 'options': list([ + 'no_error', + 'unable_to_start_or_resume', + 'unable_to_complete_operation', + 'command_invalid_in_state', + 'failed_to_find_charging_dock', + 'stuck', + 'dust_bin_missing', + 'dust_bin_full', + 'water_tank_empty', + 'water_tank_missing', + 'water_tank_lid_open', + 'mop_cleaning_pad_missing', + 'low_battery', + 'cannot_reach_target_area', + 'dirty_water_tank_full', + 'dirty_water_tank_missing', + 'wheels_jammed', + 'brush_jammed', + 'navigation_sensor_obscured', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_vacuum_operational_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_error', + }) +# --- # name: test_sensors[vacuum_cleaner][sensor.mock_vacuum_operational_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 2414bafc80d03c..b8421f337c92cf 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -374,6 +374,31 @@ async def test_operational_state_sensor( assert state.state == "extra_state" +@pytest.mark.parametrize("node_fixture", ["silabs_dishwasher"]) +async def test_operational_error_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test Operational Error sensor, using a dishwasher fixture.""" + # OperationalState Cluster / OperationalError attribute (1/96/5) + state = hass.states.get("sensor.dishwasher_operational_error") + assert state + assert state.state == "no_error" + assert state.attributes["options"] == [ + "no_error", + "unable_to_start_or_resume", + "unable_to_complete_operation", + "command_invalid_in_state", + ] + set_node_attribute(matter_node, 1, 96, 5, {0: 1}) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.dishwasher_operational_error") + assert state + assert state.state == "unable_to_start_or_resume" + + @pytest.mark.parametrize("node_fixture", ["yandex_smart_socket"]) async def test_draft_electrical_measurement_sensor( hass: HomeAssistant, @@ -623,3 +648,52 @@ async def test_vacuum_actions( state = hass.states.get("sensor.mock_vacuum_estimated_end_time") assert state assert state.state == "2025-08-29T21:13:20+00:00" + + +@pytest.mark.parametrize("node_fixture", ["vacuum_cleaner"]) +async def test_vacuum_operational_error_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test RVC Operational Error sensor, using a vacuum cleaner fixture.""" + # RvcOperationalState Cluster / OperationalError attribute (1/97/5) + state = hass.states.get("sensor.mock_vacuum_operational_error") + assert state + assert state.state == "no_error" + assert state.attributes["options"] == [ + "no_error", + "unable_to_start_or_resume", + "unable_to_complete_operation", + "command_invalid_in_state", + "failed_to_find_charging_dock", + "stuck", + "dust_bin_missing", + "dust_bin_full", + "water_tank_empty", + "water_tank_missing", + "water_tank_lid_open", + "mop_cleaning_pad_missing", + "low_battery", + "cannot_reach_target_area", + "dirty_water_tank_full", + "dirty_water_tank_missing", + "wheels_jammed", + "brush_jammed", + "navigation_sensor_obscured", + ] + # test Rvc error + set_node_attribute(matter_node, 1, 97, 5, {0: 66}) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.mock_vacuum_operational_error") + assert state + assert state.state == "dust_bin_missing" + + # test unknown errorStateID == 192 (0xC0) + set_node_attribute(matter_node, 1, 97, 5, {0: 192}) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.mock_vacuum_operational_error") + assert state + assert state.state == "unknown" diff --git a/tests/components/niko_home_control/conftest.py b/tests/components/niko_home_control/conftest.py index b60a597a9ff208..19890bf8d49411 100644 --- a/tests/components/niko_home_control/conftest.py +++ b/tests/components/niko_home_control/conftest.py @@ -5,6 +5,7 @@ from nhc.cover import NHCCover from nhc.light import NHCLight +from nhc.scene import NHCScene import pytest from homeassistant.components.niko_home_control.const import DOMAIN @@ -61,9 +62,21 @@ def cover() -> NHCCover: return mock +@pytest.fixture +def scene() -> NHCScene: + """Return a scene mock.""" + mock = AsyncMock(spec=NHCScene) + mock.id = 4 + mock.type = 0 + mock.name = "scene" + mock.suggested_area = "room" + mock.state = 0 + return mock + + @pytest.fixture def mock_niko_home_control_connection( - light: NHCLight, dimmable_light: NHCLight, cover: NHCCover + light: NHCLight, dimmable_light: NHCLight, cover: NHCCover, scene: NHCScene ) -> Generator[AsyncMock]: """Mock a NHC client.""" with ( @@ -79,6 +92,7 @@ def mock_niko_home_control_connection( client = mock_client.return_value client.lights = [light, dimmable_light] client.covers = [cover] + client.scenes = [scene] client.connect = AsyncMock(return_value=True) yield client diff --git a/tests/components/niko_home_control/snapshots/test_scene.ambr b/tests/components/niko_home_control/snapshots/test_scene.ambr new file mode 100644 index 00000000000000..6887b1b373e0d9 --- /dev/null +++ b/tests/components/niko_home_control/snapshots/test_scene.ambr @@ -0,0 +1,49 @@ +# serializer version: 1 +# name: test_entities[scene.scene-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'scene', + 'entity_category': None, + 'entity_id': 'scene.scene', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'niko_home_control', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JFN93M7KRA38V5AMPCJ2JYYV-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[scene.scene-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'scene', + }), + 'context': , + 'entity_id': 'scene.scene', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-10-10T21:00:00+00:00', + }) +# --- diff --git a/tests/components/niko_home_control/test_scene.py b/tests/components/niko_home_control/test_scene.py new file mode 100644 index 00000000000000..1f0868b1bbdd12 --- /dev/null +++ b/tests/components/niko_home_control/test_scene.py @@ -0,0 +1,82 @@ +"""Tests for the Niko Home Control Scene platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import find_update_callback, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.freeze_time("2025-10-10 21:00:00") +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_niko_home_control_connection: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.niko_home_control.PLATFORMS", [Platform.SCENE] + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize("scene_id", [0]) +async def test_activate_scene( + hass: HomeAssistant, + mock_niko_home_control_connection: AsyncMock, + mock_config_entry: MockConfigEntry, + scene_id: int, + entity_registry: er.EntityRegistry, +) -> None: + """Test activating the scene.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + SCENE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "scene.scene"}, + blocking=True, + ) + mock_niko_home_control_connection.scenes[scene_id].activate.assert_called_once() + + +async def test_updating( + hass: HomeAssistant, + mock_niko_home_control_connection: AsyncMock, + mock_config_entry: MockConfigEntry, + scene: AsyncMock, +) -> None: + """Test scene state recording after activation.""" + await setup_integration(hass, mock_config_entry) + + # Resolve the created scene entity dynamically + entity_entries = er.async_entries_for_config_entry( + er.async_get(hass), mock_config_entry.entry_id + ) + scene_entities = [e for e in entity_entries if e.domain == SCENE_DOMAIN] + assert scene_entities, "No scene entities registered" + entity_id = scene_entities[0].entity_id + + # Capture current state (could be unknown or a timestamp depending on implementation) + before = hass.states.get(entity_id) + assert before is not None + + # Simulate a device-originated update for the scene (controller callback) + await find_update_callback(mock_niko_home_control_connection, scene.id)(0) + await hass.async_block_till_done() + + after = hass.states.get(entity_id) + assert after is not None + assert after.state != before.state diff --git a/tests/components/octoprint/__init__.py b/tests/components/octoprint/__init__.py index 3755b84a6f9760..5e58fb981c29cb 100644 --- a/tests/components/octoprint/__init__.py +++ b/tests/components/octoprint/__init__.py @@ -53,7 +53,7 @@ async def init_integration( platform: Platform, printer: dict[str, Any] | UndefinedType | None = UNDEFINED, job: dict[str, Any] | None = None, -) -> None: +) -> MockConfigEntry: """Set up the octoprint integration in Home Assistant.""" printer_info: OctoprintPrinterInfo | None = None if printer is UNDEFINED: @@ -102,3 +102,4 @@ async def init_integration( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED + return config_entry diff --git a/tests/components/octoprint/snapshots/test_number.ambr b/tests/components/octoprint/snapshots/test_number.ambr new file mode 100644 index 00000000000000..518374e636a2da --- /dev/null +++ b/tests/components/octoprint/snapshots/test_number.ambr @@ -0,0 +1,178 @@ +# serializer version: 1 +# name: test_numbers[number.octoprint_bed_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 300, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.octoprint_bed_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Bed temperature', + 'platform': 'octoprint', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bed_temperature', + 'unique_id': 'uuid_bed_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[number.octoprint_bed_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'OctoPrint Bed temperature', + 'max': 300, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.octoprint_bed_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60.0', + }) +# --- +# name: test_numbers[number.octoprint_extruder_1_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 300, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.octoprint_extruder_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Extruder 1 temperature', + 'platform': 'octoprint', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'extruder_n_temperature', + 'unique_id': 'uuid_tool1_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[number.octoprint_extruder_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'OctoPrint Extruder 1 temperature', + 'max': 300, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.octoprint_extruder_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '31.0', + }) +# --- +# name: test_numbers[number.octoprint_extruder_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 300, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.octoprint_extruder_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Extruder temperature', + 'platform': 'octoprint', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'extruder_temperature', + 'unique_id': 'uuid_tool0_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[number.octoprint_extruder_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'OctoPrint Extruder temperature', + 'max': 300, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.octoprint_extruder_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37.83136', + }) +# --- diff --git a/tests/components/octoprint/test_number.py b/tests/components/octoprint/test_number.py new file mode 100644 index 00000000000000..d0eaa982801ed3 --- /dev/null +++ b/tests/components/octoprint/test_number.py @@ -0,0 +1,227 @@ +"""The tests for OctoPrint number module.""" + +from datetime import UTC, datetime +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import snapshot_platform + + +async def test_numbers( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the underlying number entities.""" + printer = { + "state": { + "flags": {"printing": True}, + "text": "Operational", + }, + "temperature": { + "tool0": {"actual": 18.83136, "target": 37.83136}, + "tool1": {"actual": 21.0, "target": 31.0}, + "bed": {"actual": 25.5, "target": 60.0}, + }, + } + job = __standard_job() + freezer.move_to(datetime(2020, 2, 20, 9, 10, 13, 543, tzinfo=UTC)) + config_entry = await init_integration(hass, "number", printer=printer, job=job) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +async def test_numbers_no_target_temp( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + entity_registry: er.EntityRegistry, +) -> None: + """Test the number entities when target temperature is None.""" + printer = { + "state": { + "flags": {"printing": True, "paused": False}, + "text": "Operational", + }, + "temperature": { + "tool0": {"actual": 18.83136, "target": None}, + "bed": {"actual": 25.5, "target": None}, + }, + } + freezer.move_to(datetime(2020, 2, 20, 9, 10, 0)) + await init_integration(hass, "number", printer=printer) + + state = hass.states.get("number.octoprint_extruder_temperature") + assert state is not None + assert state.state == STATE_UNKNOWN + assert state.name == "OctoPrint Extruder temperature" + entry = entity_registry.async_get("number.octoprint_extruder_temperature") + assert entry.unique_id == "uuid_tool0_temperature" + + state = hass.states.get("number.octoprint_bed_temperature") + assert state is not None + assert state.state == STATE_UNKNOWN + assert state.name == "OctoPrint Bed temperature" + entry = entity_registry.async_get("number.octoprint_bed_temperature") + assert entry.unique_id == "uuid_bed_temperature" + + +async def test_set_tool_temp( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test setting tool temperature via number entity.""" + printer = { + "state": { + "flags": {"printing": False}, + "text": "Operational", + }, + "temperature": {"tool0": {"actual": 18.83136, "target": 25.0}}, + } + job = __standard_job() + freezer.move_to(datetime(2020, 2, 20, 9, 10, 0)) + await init_integration(hass, "number", printer=printer, job=job) + + with patch( + "pyoctoprintapi.OctoprintClient.set_tool_temperature" + ) as mock_set_tool_temp: + entity_component = hass.data[NUMBER_DOMAIN] + + entity = entity_component.get_entity("number.octoprint_extruder_temperature") + assert entity is not None + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity.entity_id, ATTR_VALUE: 200.4}, + blocking=True, + ) + assert len(mock_set_tool_temp.mock_calls) == 1 + # Verify that we pass integer, expected by the pyoctoprintapi + mock_set_tool_temp.assert_called_with("tool0", 200) + + +async def test_set_bed_temp( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test setting bed temperature via number entity.""" + printer = { + "state": { + "flags": {"printing": False}, + "text": "Operational", + }, + "temperature": {"bed": {"actual": 20.0, "target": 50.0}}, + } + job = __standard_job() + freezer.move_to(datetime(2020, 2, 20, 9, 10, 0)) + await init_integration(hass, "number", printer=printer, job=job) + + with patch( + "pyoctoprintapi.OctoprintClient.set_bed_temperature" + ) as mock_set_bed_temp: + entity_component = hass.data[NUMBER_DOMAIN] + entity = entity_component.get_entity("number.octoprint_bed_temperature") + assert entity is not None + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity.entity_id, ATTR_VALUE: 80.6}, + blocking=True, + ) + + assert len(mock_set_bed_temp.mock_calls) == 1 + # Verify that we pass integer, expected by the pyoctoprintapi, and that it's rounded down + mock_set_bed_temp.assert_called_with(80) + + +async def test_set_tool_n_temp( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test setting tool temperature via number entity when multiple tools are present.""" + printer = { + "state": { + "flags": {"printing": False}, + "text": "Operational", + }, + "temperature": { + "tool0": {"actual": 20.0, "target": 30.0}, + "tool1": {"actual": 21.0, "target": 31.0}, + }, + } + job = __standard_job() + freezer.move_to(datetime(2020, 2, 20, 9, 10, 0)) + await init_integration(hass, "number", printer=printer, job=job) + + with patch( + "pyoctoprintapi.OctoprintClient.set_tool_temperature" + ) as mock_set_tool_temp: + entity_component = hass.data[NUMBER_DOMAIN] + + entity = entity_component.get_entity("number.octoprint_extruder_1_temperature") + assert entity is not None + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity.entity_id, ATTR_VALUE: 41.0}, + blocking=True, + ) + assert len(mock_set_tool_temp.mock_calls) == 1 + # Verify that we pass integer, expected by the pyoctoprintapi + mock_set_tool_temp.assert_called_with("tool1", 41) + + +async def test_numbers_printer_disconnected( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + entity_registry: er.EntityRegistry, +) -> None: + """Test number entities when printer is disconnected.""" + job = __standard_job() + freezer.move_to(datetime(2020, 2, 20, 9, 10, 0)) + await init_integration(hass, "number", printer=None, job=job) + + # When printer is disconnected, no number entities should be created + state = hass.states.get("number.octoprint_tool0_temperature") + assert state is None + + state = hass.states.get("number.octoprint_bed_temperature") + assert state is None + + +def __standard_job(): + return { + "job": { + "averagePrintTime": 6500, + "estimatedPrintTime": 6000, + "filament": {"tool0": {"length": 3000, "volume": 7}}, + "file": { + "date": 1577836800, + "display": "Test File Name", + "name": "Test_File_Name.gcode", + "origin": "local", + "path": "Folder1/Folder2/Test_File_Name.gcode", + "size": 123456789, + }, + "lastPrintTime": 12345.678, + "user": "testUser", + }, + "progress": {"completion": 50, "printTime": 600, "printTimeLeft": 6000}, + "state": "Printing", + } diff --git a/tests/components/uptime_kuma/test_sensor.py b/tests/components/uptime_kuma/test_sensor.py index 25bd76505286e3..873c16c4174bac 100644 --- a/tests/components/uptime_kuma/test_sensor.py +++ b/tests/components/uptime_kuma/test_sensor.py @@ -9,10 +9,11 @@ from pythonkuma import MonitorStatus, UptimeKumaMonitor, UptimeKumaVersion from syrupy.assertion import SnapshotAssertion +from homeassistant.components.uptime_kuma.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -53,6 +54,7 @@ async def test_migrate_unique_id( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, freezer: FrozenDateTimeFactory, + device_registry: dr.DeviceRegistry, ) -> None: """Snapshot test states of sensor platform.""" mock_pythonkuma.metrics.return_value = { @@ -87,7 +89,7 @@ async def test_migrate_unique_id( ) } mock_pythonkuma.version = UptimeKumaVersion( - version="2.0.0-beta.3", major="2", minor="0", patch="0-beta.3" + version="2.0.2", major="2", minor="0", patch="2" ) freezer.tick(timedelta(seconds=30)) async_fire_time_changed(hass) @@ -95,3 +97,10 @@ async def test_migrate_unique_id( assert (entity := entity_registry.async_get("sensor.monitor_status")) assert entity.unique_id == "123456789_1_status" + + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, f"{entity.config_entry_id}_1")} + ) + ) + assert device.sw_version == "2.0.2" diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 4a50cb9399fd09..74e81ebeb6ef5e 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -3181,12 +3181,9 @@ async def test_repeat_limits( title_condition = condition.title() - assert f"{title_condition} condition" in caplog.text - assert f"in script `Test {condition}` looped 5 times" in caplog.text - assert ( - f"script `Test {condition}` terminated because it looped 10 times" - in caplog.text - ) + assert f"Test {condition}: {title_condition} condition" in caplog.text + assert "looped 5 times" in caplog.text + assert "terminated because it looped 10 times" in caplog.text assert len(events) == 10 diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index b07e9a3f7e549d..52a3457d69cf32 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -8887,7 +8887,7 @@ async def test_state_cache_is_cleared_on_entry_disable(hass: HomeAssistant) -> N "source", [config_entries.SOURCE_REAUTH, config_entries.SOURCE_RECONFIGURE], ) -async def test_create_entry_reauth_reconfigure( +async def test_create_entry_reauth_reconfigure_fails( hass: HomeAssistant, source: str, original_unique_id: str | None, @@ -8937,25 +8937,9 @@ async def _async_step_confirm(self): with ( mock_config_flow("test", TestFlow), - patch.object(frame, "_REPORTED_INTEGRATIONS", set()), + pytest.raises(HomeAssistantError), ): - result = await getattr(entry, f"start_{source}_flow")(hass) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.CREATE_ENTRY - - entries = hass.config_entries.async_entries("test") - assert len(entries) == count - if count == 1: - # Show that the previous entry got binned and recreated - assert entries[0].entry_id != entry.entry_id - - assert ( - f"Detected that integration 'test' creates a new entry in a '{source}' flow, " - "when it is expected to update an existing entry and abort. This will stop " - "working in Home Assistant 2025.11, please create a bug report at " - "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+" - "label%3A%22integration%3A+test%22" - ) in caplog.text + await getattr(entry, f"start_{source}_flow")(hass) async def test_async_update_entry_unique_id_collision(