diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index afe3b2d7aa3a3..e16c29ceaa8c2 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -402,6 +402,8 @@ async def async_deactivate_user(self, user: models.User) -> None: if user.is_owner: raise ValueError("Unable to deactivate the owner") await self._store.async_deactivate_user(user) + for refresh_token in list(user.refresh_tokens.values()): + self.async_remove_refresh_token(refresh_token) async def async_remove_credentials(self, credentials: models.Credentials) -> None: """Remove credentials.""" diff --git a/homeassistant/components/asuswrt/manifest.json b/homeassistant/components/asuswrt/manifest.json index d8feff1a59d80..bbc8b2675aee4 100644 --- a/homeassistant/components/asuswrt/manifest.json +++ b/homeassistant/components/asuswrt/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aioasuswrt", "asusrouter", "asyncssh"], - "requirements": ["aioasuswrt==1.5.2", "asusrouter==1.21.3"] + "requirements": ["aioasuswrt==1.5.4", "asusrouter==1.21.3"] } diff --git a/homeassistant/components/blink/manifest.json b/homeassistant/components/blink/manifest.json index c216df245e5c1..b6b15cababac5 100644 --- a/homeassistant/components/blink/manifest.json +++ b/homeassistant/components/blink/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/blink", "iot_class": "cloud_polling", "loggers": ["blinkpy"], - "requirements": ["blinkpy==0.25.1"] + "requirements": ["blinkpy==0.25.2"] } diff --git a/homeassistant/components/config/area_registry.py b/homeassistant/components/config/area_registry.py index f97bb7ddd8a4e..3e0a9c1df5fab 100644 --- a/homeassistant/components/config/area_registry.py +++ b/homeassistant/components/config/area_registry.py @@ -65,8 +65,10 @@ def websocket_create_area( data.pop("id") if "aliases" in data: - # Convert aliases to a set - data["aliases"] = set(data["aliases"]) + # Create a set for the aliases without: + # - Empty strings + # - Trailing and leading whitespace characters in the individual aliases + data["aliases"] = {s_strip for s in data["aliases"] if (s_strip := s.strip())} if "labels" in data: # Convert labels to a set @@ -133,8 +135,10 @@ def websocket_update_area( data.pop("id") if "aliases" in data: - # Convert aliases to a set - data["aliases"] = set(data["aliases"]) + # Create a set for the aliases without: + # - Empty strings + # - Trailing and leading whitespace characters in the individual aliases + data["aliases"] = {s_strip for s in data["aliases"] if (s_strip := s.strip())} if "labels" in data: # Convert labels to a set diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py index d619b58523040..a1ce5645d6bac 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -227,8 +227,10 @@ def websocket_update_entity( changes[key] = msg[key] if "aliases" in msg: - # Convert aliases to a set - changes["aliases"] = set(msg["aliases"]) + # Create a set for the aliases without: + # - Empty strings + # - Trailing and leading whitespace characters in the individual aliases + changes["aliases"] = {s_strip for s in msg["aliases"] if (s_strip := s.strip())} if "labels" in msg: # Convert labels to a set diff --git a/homeassistant/components/config/floor_registry.py b/homeassistant/components/config/floor_registry.py index f33051dfc7fca..a4545193979f8 100644 --- a/homeassistant/components/config/floor_registry.py +++ b/homeassistant/components/config/floor_registry.py @@ -61,8 +61,10 @@ def websocket_create_floor( data.pop("id") if "aliases" in data: - # Convert aliases to a set - data["aliases"] = set(data["aliases"]) + # Create a set for the aliases without: + # - Empty strings + # - Trailing and leading whitespace characters in the individual aliases + data["aliases"] = {s_strip for s in data["aliases"] if (s_strip := s.strip())} try: entry = registry.async_create(**data) @@ -117,8 +119,10 @@ def websocket_update_floor( data.pop("id") if "aliases" in data: - # Convert aliases to a set - data["aliases"] = set(data["aliases"]) + # Create a set for the aliases without: + # - Empty strings + # - Trailing and leading whitespace characters in the individual aliases + data["aliases"] = {s_strip for s in data["aliases"] if (s_strip := s.strip())} try: entry = registry.async_update(**data) diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index bcb217e1d01a5..505df119d378e 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -9,7 +9,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.46.0", "getmac==0.9.5"], + "requirements": ["async-upnp-client==0.46.1", "getmac==0.9.5"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json index 8eee761a70c0c..9e6216dffdfbd 100644 --- a/homeassistant/components/dlna_dms/manifest.json +++ b/homeassistant/components/dlna_dms/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dms", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["async-upnp-client==0.46.0"], + "requirements": ["async-upnp-client==0.46.1"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", diff --git a/homeassistant/components/dnsip/manifest.json b/homeassistant/components/dnsip/manifest.json index 6008fb83e1b4b..74db3009c4b20 100644 --- a/homeassistant/components/dnsip/manifest.json +++ b/homeassistant/components/dnsip/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dnsip", "iot_class": "cloud_polling", - "requirements": ["aiodns==3.5.0"] + "requirements": ["aiodns==3.6.1"] } diff --git a/homeassistant/components/ekeybionyx/manifest.json b/homeassistant/components/ekeybionyx/manifest.json index a53dc13b9936c..de48e90df980f 100644 --- a/homeassistant/components/ekeybionyx/manifest.json +++ b/homeassistant/components/ekeybionyx/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/ekeybionyx", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["ekey-bionyxpy==1.0.0"] + "requirements": ["ekey-bionyxpy==1.0.1"] } diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index ffe4bc713e149..4285eb7936f9e 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -23,5 +23,5 @@ "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20251203.2"] + "requirements": ["home-assistant-frontend==20251203.3"] } diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index 5b619139d5e3f..837263474a3ea 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -8,5 +8,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], - "requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==12.1.1"] + "requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==12.1.2"] } diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 54ad66a23794f..593d827864df1 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -908,12 +908,21 @@ def query_attributes(self) -> dict[str, Any]: } if domain in COVER_VALVE_DOMAINS: + assumed_state_or_set_position = bool( + ( + self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + & COVER_VALVE_SET_POSITION_FEATURE[domain] + ) + or self.state.attributes.get(ATTR_ASSUMED_STATE) + ) + return { "isRunning": state in ( COVER_VALVE_STATES[domain]["closing"], COVER_VALVE_STATES[domain]["opening"], ) + or assumed_state_or_set_position } raise NotImplementedError(f"Unsupported domain {domain}") @@ -975,11 +984,23 @@ async def _execute_cover_or_valve(self, command, data, params, challenge): """Execute a StartStop command.""" domain = self.state.domain if command == COMMAND_START_STOP: + assumed_state_or_set_position = bool( + ( + self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + & COVER_VALVE_SET_POSITION_FEATURE[domain] + ) + or self.state.attributes.get(ATTR_ASSUMED_STATE) + ) + if params["start"] is False: - if self.state.state in ( - COVER_VALVE_STATES[domain]["closing"], - COVER_VALVE_STATES[domain]["opening"], - ) or self.state.attributes.get(ATTR_ASSUMED_STATE): + if ( + self.state.state + in ( + COVER_VALVE_STATES[domain]["closing"], + COVER_VALVE_STATES[domain]["opening"], + ) + or assumed_state_or_set_position + ): await self.hass.services.async_call( domain, SERVICE_STOP_COVER_VALVE[domain], @@ -992,7 +1013,14 @@ async def _execute_cover_or_valve(self, command, data, params, challenge): ERR_ALREADY_STOPPED, f"{FRIENDLY_DOMAIN[domain]} is already stopped", ) - else: + elif ( + self.state.state + in ( + COVER_VALVE_STATES[domain]["open"], + COVER_VALVE_STATES[domain]["closed"], + ) + or assumed_state_or_set_position + ): await self.hass.services.async_call( domain, SERVICE_TOGGLE_COVER_VALVE[domain], diff --git a/homeassistant/components/icloud/config_flow.py b/homeassistant/components/icloud/config_flow.py index efcef15b4d038..f1d7dc474559e 100644 --- a/homeassistant/components/icloud/config_flow.py +++ b/homeassistant/components/icloud/config_flow.py @@ -16,7 +16,7 @@ ) import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers.storage import Store @@ -155,8 +155,8 @@ async def _validate_and_create_entry(self, user_input, step_id): CONF_GPS_ACCURACY_THRESHOLD: self._gps_accuracy_threshold, } - # If this is a password update attempt, update the entry instead of creating one - if step_id == "user": + # If this is a password update attempt, don't try and creating one + if self.source == SOURCE_USER: return self.async_create_entry(title=self._username, data=data) entry = await self.async_set_unique_id(self.unique_id) diff --git a/homeassistant/components/lcn/binary_sensor.py b/homeassistant/components/lcn/binary_sensor.py index 5d04967631b5f..4f813ca4c0020 100644 --- a/homeassistant/components/lcn/binary_sensor.py +++ b/homeassistant/components/lcn/binary_sensor.py @@ -19,8 +19,8 @@ from .entity import LcnEntity from .helpers import InputType, LcnConfigEntry -PARALLEL_UPDATES = 0 -SCAN_INTERVAL = timedelta(minutes=1) +PARALLEL_UPDATES = 2 +SCAN_INTERVAL = timedelta(minutes=10) def add_lcn_entities( diff --git a/homeassistant/components/lcn/climate.py b/homeassistant/components/lcn/climate.py index 0874d91668424..cf8fd2445275b 100644 --- a/homeassistant/components/lcn/climate.py +++ b/homeassistant/components/lcn/climate.py @@ -37,7 +37,7 @@ from .entity import LcnEntity from .helpers import InputType, LcnConfigEntry -PARALLEL_UPDATES = 0 +PARALLEL_UPDATES = 2 SCAN_INTERVAL = timedelta(minutes=1) diff --git a/homeassistant/components/lcn/cover.py b/homeassistant/components/lcn/cover.py index 7df79ef02b1d7..648e2b1b0abd9 100644 --- a/homeassistant/components/lcn/cover.py +++ b/homeassistant/components/lcn/cover.py @@ -28,7 +28,7 @@ from .entity import LcnEntity from .helpers import InputType, LcnConfigEntry -PARALLEL_UPDATES = 0 +PARALLEL_UPDATES = 2 SCAN_INTERVAL = timedelta(minutes=1) diff --git a/homeassistant/components/lcn/light.py b/homeassistant/components/lcn/light.py index f5b0f8732a598..be6ac6935cdad 100644 --- a/homeassistant/components/lcn/light.py +++ b/homeassistant/components/lcn/light.py @@ -33,8 +33,8 @@ BRIGHTNESS_SCALE = (1, 100) -PARALLEL_UPDATES = 0 -SCAN_INTERVAL = timedelta(minutes=1) +PARALLEL_UPDATES = 2 +SCAN_INTERVAL = timedelta(minutes=10) def add_lcn_entities( diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index a5c1c0f828d83..a3e3fae43a8d5 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_polling", "loggers": ["pypck"], "quality_scale": "silver", - "requirements": ["pypck==0.9.7", "lcn-frontend==0.2.7"] + "requirements": ["pypck==0.9.8", "lcn-frontend==0.2.7"] } diff --git a/homeassistant/components/lcn/scene.py b/homeassistant/components/lcn/scene.py index 1d6839b5d9132..e2089cda950c5 100644 --- a/homeassistant/components/lcn/scene.py +++ b/homeassistant/components/lcn/scene.py @@ -22,7 +22,7 @@ from .entity import LcnEntity from .helpers import LcnConfigEntry -PARALLEL_UPDATES = 0 +PARALLEL_UPDATES = 2 def add_lcn_entities( diff --git a/homeassistant/components/lcn/sensor.py b/homeassistant/components/lcn/sensor.py index 667ac88f750fc..3515d6ab5f574 100644 --- a/homeassistant/components/lcn/sensor.py +++ b/homeassistant/components/lcn/sensor.py @@ -40,7 +40,7 @@ from .entity import LcnEntity from .helpers import InputType, LcnConfigEntry -PARALLEL_UPDATES = 0 +PARALLEL_UPDATES = 2 SCAN_INTERVAL = timedelta(minutes=1) diff --git a/homeassistant/components/lcn/switch.py b/homeassistant/components/lcn/switch.py index d370d74d2dd09..c18c92215a95c 100644 --- a/homeassistant/components/lcn/switch.py +++ b/homeassistant/components/lcn/switch.py @@ -17,8 +17,8 @@ from .entity import LcnEntity from .helpers import InputType, LcnConfigEntry -PARALLEL_UPDATES = 0 -SCAN_INTERVAL = timedelta(minutes=1) +PARALLEL_UPDATES = 2 +SCAN_INTERVAL = timedelta(minutes=10) def add_lcn_switch_entities( diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index 58cf7ebc9d728..8447babb73736 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==12.1.1"] + "requirements": ["ical==12.1.2"] } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index 81e895c8df4cb..3980f0e5c6422 100644 --- a/homeassistant/components/local_todo/manifest.json +++ b/homeassistant/components/local_todo/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_todo", "iot_class": "local_polling", - "requirements": ["ical==12.1.1"] + "requirements": ["ical==12.1.2"] } diff --git a/homeassistant/components/mealie/calendar.py b/homeassistant/components/mealie/calendar.py index 556ddede2e239..4d5325f235f4d 100644 --- a/homeassistant/components/mealie/calendar.py +++ b/homeassistant/components/mealie/calendar.py @@ -15,6 +15,13 @@ PARALLEL_UPDATES = 0 +SUPPORTED_MEALPLAN_ENTRY_TYPES = [ + MealplanEntryType.BREAKFAST, + MealplanEntryType.DINNER, + MealplanEntryType.LUNCH, + MealplanEntryType.SIDE, +] + async def async_setup_entry( hass: HomeAssistant, @@ -26,7 +33,7 @@ async def async_setup_entry( async_add_entities( MealieMealplanCalendarEntity(coordinator, entry_type) - for entry_type in MealplanEntryType + for entry_type in SUPPORTED_MEALPLAN_ENTRY_TYPES ) diff --git a/homeassistant/components/mealie/manifest.json b/homeassistant/components/mealie/manifest.json index 8a561c9c0b5b6..5e090a6af738b 100644 --- a/homeassistant/components/mealie/manifest.json +++ b/homeassistant/components/mealie/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["aiomealie==1.1.0"] + "requirements": ["aiomealie==1.1.1"] } diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py index 4a18a340ff238..30e4318e0b4d1 100644 --- a/homeassistant/components/miele/const.py +++ b/homeassistant/components/miele/const.py @@ -98,50 +98,28 @@ class MieleAppliance(IntEnum): } -class StateStatus(IntEnum): +class StateStatus(MieleEnum, missing_to_none=True): """Define appliance states.""" - RESERVED = 0 - OFF = 1 - ON = 2 - PROGRAMMED = 3 - WAITING_TO_START = 4 - IN_USE = 5 - PAUSE = 6 - PROGRAM_ENDED = 7 - FAILURE = 8 - PROGRAM_INTERRUPTED = 9 - IDLE = 10 - RINSE_HOLD = 11 - SERVICE = 12 - SUPERFREEZING = 13 - SUPERCOOLING = 14 - SUPERHEATING = 15 - SUPERCOOLING_SUPERFREEZING = 146 - AUTOCLEANING = 147 - NOT_CONNECTED = 255 - - -STATE_STATUS_TAGS = { - StateStatus.OFF: "off", - StateStatus.ON: "on", - StateStatus.PROGRAMMED: "programmed", - StateStatus.WAITING_TO_START: "waiting_to_start", - StateStatus.IN_USE: "in_use", - StateStatus.PAUSE: "pause", - StateStatus.PROGRAM_ENDED: "program_ended", - StateStatus.FAILURE: "failure", - StateStatus.PROGRAM_INTERRUPTED: "program_interrupted", - StateStatus.IDLE: "idle", - StateStatus.RINSE_HOLD: "rinse_hold", - StateStatus.SERVICE: "service", - StateStatus.SUPERFREEZING: "superfreezing", - StateStatus.SUPERCOOLING: "supercooling", - StateStatus.SUPERHEATING: "superheating", - StateStatus.SUPERCOOLING_SUPERFREEZING: "supercooling_superfreezing", - StateStatus.AUTOCLEANING: "autocleaning", - StateStatus.NOT_CONNECTED: "not_connected", -} + reserved = 0 + off = 1 + on = 2 + programmed = 3 + waiting_to_start = 4 + in_use = 5 + pause = 6 + program_ended = 7 + failure = 8 + program_interrupted = 9 + idle = 10 + rinse_hold = 11 + service = 12 + superfreezing = 13 + supercooling = 14 + superheating = 15 + supercooling_superfreezing = 146 + autocleaning = 147 + not_connected = 255 class MieleActions(IntEnum): diff --git a/homeassistant/components/miele/entity.py b/homeassistant/components/miele/entity.py index ff2207fd0aa31..93e109d3500bc 100644 --- a/homeassistant/components/miele/entity.py +++ b/homeassistant/components/miele/entity.py @@ -73,5 +73,5 @@ def available(self) -> bool: return ( super().available and self._device_id in self.coordinator.data.devices - and (self.device.state_status is not StateStatus.NOT_CONNECTED) + and (self.device.state_status is not StateStatus.not_connected) ) diff --git a/homeassistant/components/miele/sensor.py b/homeassistant/components/miele/sensor.py index 4d51beba4d81f..6ed7940c807c7 100644 --- a/homeassistant/components/miele/sensor.py +++ b/homeassistant/components/miele/sensor.py @@ -38,7 +38,6 @@ DOMAIN, PROGRAM_IDS, PROGRAM_PHASE, - STATE_STATUS_TAGS, MieleAppliance, PlatePowerStep, StateDryingStep, @@ -195,7 +194,7 @@ class MieleSensorDefinition: translation_key="status", value_fn=lambda value: value.state_status, device_class=SensorDeviceClass.ENUM, - options=sorted(set(STATE_STATUS_TAGS.values())), + options=sorted(set(StateStatus.keys())), ), ), MieleSensorDefinition( @@ -930,7 +929,7 @@ def __init__( @property def native_value(self) -> StateType: """Return the state of the sensor.""" - return STATE_STATUS_TAGS.get(StateStatus(self.device.state_status)) + return StateStatus(self.device.state_status).name @property def available(self) -> bool: @@ -998,11 +997,11 @@ def _update_native_value(self) -> None: """Update the last value of the sensor.""" current_value = self.entity_description.value_fn(self.device) - current_status = StateStatus(self.device.state_status) + current_status = StateStatus(self.device.state_status).name # report end-specific value when program ends (some devices are immediately reporting 0...) if ( - current_status == StateStatus.PROGRAM_ENDED + current_status == StateStatus.program_ended.name and self.entity_description.end_value_fn is not None ): self._attr_native_value = self.entity_description.end_value_fn( @@ -1010,11 +1009,15 @@ def _update_native_value(self) -> None: ) # keep value when program ends if no function is specified - elif current_status == StateStatus.PROGRAM_ENDED: + elif current_status == StateStatus.program_ended.name: pass # force unknown when appliance is not working (some devices are keeping last value until a new cycle starts) - elif current_status in (StateStatus.OFF, StateStatus.ON, StateStatus.IDLE): + elif current_status in ( + StateStatus.off.name, + StateStatus.on.name, + StateStatus.idle.name, + ): self._attr_native_value = None # otherwise, cache value and return it @@ -1030,7 +1033,7 @@ class MieleAbsoluteTimeSensor(MieleRestorableSensor): def _update_native_value(self) -> None: """Update the last value of the sensor.""" current_value = self.entity_description.value_fn(self.device) - current_status = StateStatus(self.device.state_status) + current_status = StateStatus(self.device.state_status).name # The API reports with minute precision, to avoid changing # the value too often, we keep the cached value if it differs @@ -1043,11 +1046,15 @@ def _update_native_value(self) -> None: < current_value < self._previous_value + timedelta(seconds=90) ) - ) or current_status == StateStatus.PROGRAM_ENDED: + ) or current_status == StateStatus.program_ended.name: return # force unknown when appliance is not working (some devices are keeping last value until a new cycle starts) - if current_status in (StateStatus.OFF, StateStatus.ON, StateStatus.IDLE): + if current_status in ( + StateStatus.off.name, + StateStatus.on.name, + StateStatus.idle.name, + ): self._attr_native_value = None # otherwise, cache value and return it @@ -1064,7 +1071,7 @@ class MieleConsumptionSensor(MieleRestorableSensor): def _update_native_value(self) -> None: """Update the last value of the sensor.""" current_value = self.entity_description.value_fn(self.device) - current_status = StateStatus(self.device.state_status) + current_status = StateStatus(self.device.state_status).name # Guard for corrupt restored value restored_value = ( self._attr_native_value @@ -1079,12 +1086,12 @@ def _update_native_value(self) -> None: # Force unknown when appliance is not able to report consumption if current_status in ( - StateStatus.ON, - StateStatus.OFF, - StateStatus.PROGRAMMED, - StateStatus.WAITING_TO_START, - StateStatus.IDLE, - StateStatus.SERVICE, + StateStatus.on.name, + StateStatus.off.name, + StateStatus.programmed.name, + StateStatus.waiting_to_start.name, + StateStatus.idle.name, + StateStatus.service.name, ): self._is_reporting = False self._attr_native_value = None @@ -1093,7 +1100,7 @@ def _update_native_value(self) -> None: # only after a while, so it is necessary to force 0 until we see the 0 value coming from API, unless # we already saw a valid value in this cycle from cache elif ( - current_status in (StateStatus.IN_USE, StateStatus.PAUSE) + current_status in (StateStatus.in_use.name, StateStatus.pause.name) and not self._is_reporting and last_value > 0 ): @@ -1101,7 +1108,7 @@ def _update_native_value(self) -> None: self._is_reporting = True elif ( - current_status in (StateStatus.IN_USE, StateStatus.PAUSE) + current_status in (StateStatus.in_use.name, StateStatus.pause.name) and not self._is_reporting and current_value is not None and cast(int, current_value) > 0 @@ -1109,7 +1116,7 @@ def _update_native_value(self) -> None: self._attr_native_value = 0 # keep value when program ends - elif current_status == StateStatus.PROGRAM_ENDED: + elif current_status == StateStatus.program_ended.name: pass else: diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index 6d55ba528408e..dcebedd60f097 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -1061,6 +1061,7 @@ "program_ended": "Program ended", "program_interrupted": "Program interrupted", "programmed": "Programmed", + "reserved": "Reserved", "rinse_hold": "Rinse hold", "service": "Service", "supercooling": "Supercooling", diff --git a/homeassistant/components/miele/switch.py b/homeassistant/components/miele/switch.py index 277cf56e639ee..f44e8d74deb59 100644 --- a/homeassistant/components/miele/switch.py +++ b/homeassistant/components/miele/switch.py @@ -58,7 +58,7 @@ class MieleSwitchDefinition: description=MieleSwitchDescription( key="supercooling", value_fn=lambda value: value.state_status, - on_value=StateStatus.SUPERCOOLING, + on_value=StateStatus.supercooling, translation_key="supercooling", on_cmd_data={PROCESS_ACTION: MieleActions.START_SUPERCOOL}, off_cmd_data={PROCESS_ACTION: MieleActions.STOP_SUPERCOOL}, @@ -73,7 +73,7 @@ class MieleSwitchDefinition: description=MieleSwitchDescription( key="superfreezing", value_fn=lambda value: value.state_status, - on_value=StateStatus.SUPERFREEZING, + on_value=StateStatus.superfreezing, translation_key="superfreezing", on_cmd_data={PROCESS_ACTION: MieleActions.START_SUPERFREEZE}, off_cmd_data={PROCESS_ACTION: MieleActions.STOP_SUPERFREEZE}, diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py index 62906ea65aeb7..206e25433e27c 100644 --- a/homeassistant/components/mold_indicator/sensor.py +++ b/homeassistant/components/mold_indicator/sensor.py @@ -315,7 +315,7 @@ def _update_temp_sensor(state: State) -> float | None: # Return an error if the sensor change its state to Unknown. if state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): - _LOGGER.error( + _LOGGER.debug( "Unable to parse temperature sensor %s with state: %s", state.entity_id, state.state, @@ -352,7 +352,7 @@ def _update_hum_sensor(state: State) -> float | None: # Return an error if the sensor change its state to Unknown. if state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): - _LOGGER.error( + _LOGGER.debug( "Unable to parse humidity sensor %s, state: %s", state.entity_id, state.state, diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py index dd9e64a92d12a..c0d56abba2b96 100644 --- a/homeassistant/components/music_assistant/__init__.py +++ b/homeassistant/components/music_assistant/__init__.py @@ -27,7 +27,11 @@ from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.issue_registry import ( @@ -101,6 +105,15 @@ async def async_setup_entry( # noqa: C901 ) raise ConfigEntryNotReady(f"Invalid server version: {err}") from err except (AuthenticationRequired, AuthenticationFailed, InvalidToken) as err: + assert mass.server_info is not None + # Users cannot reauthenticate when running as Home Assistant addon, + # so raising ConfigEntryAuthFailed in that case would be incorrect. + # Instead we should wait until the addon discovery is completed, + # as that will set up authentication and reload the entry automatically. + if mass.server_info.homeassistant_addon: + raise ConfigEntryError( + "Authentication failed, addon discovery not completed yet" + ) from err raise ConfigEntryAuthFailed( f"Authentication failed for {mass_url}: {err}" ) from err diff --git a/homeassistant/components/music_assistant/config_flow.py b/homeassistant/components/music_assistant/config_flow.py index 226a4dda28f7c..c03ae85fd049a 100644 --- a/homeassistant/components/music_assistant/config_flow.py +++ b/homeassistant/components/music_assistant/config_flow.py @@ -179,6 +179,7 @@ async def async_step_hassio( ConfigEntryState.LOADED, ConfigEntryState.SETUP_ERROR, ConfigEntryState.SETUP_RETRY, + ConfigEntryState.SETUP_IN_PROGRESS, ): self.hass.config_entries.async_schedule_reload(entry.entry_id) diff --git a/homeassistant/components/nintendo_parental_controls/__init__.py b/homeassistant/components/nintendo_parental_controls/__init__.py index 90b285ab55653..7b3649aaa74c4 100644 --- a/homeassistant/components/nintendo_parental_controls/__init__.py +++ b/homeassistant/components/nintendo_parental_controls/__init__.py @@ -2,11 +2,11 @@ from __future__ import annotations -from pynintendoparental import Authenticator -from pynintendoparental.exceptions import ( +from pynintendoauth.exceptions import ( InvalidOAuthConfigurationException, InvalidSessionTokenException, ) +from pynintendoparental import Authenticator from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -39,13 +39,12 @@ async def async_setup_entry( hass: HomeAssistant, entry: NintendoParentalControlsConfigEntry ) -> bool: """Set up Nintendo Switch parental controls from a config entry.""" + nintendo_auth = Authenticator( + session_token=entry.data[CONF_SESSION_TOKEN], + client_session=async_get_clientsession(hass), + ) try: - nintendo_auth = await Authenticator.complete_login( - auth=None, - response_token=entry.data[CONF_SESSION_TOKEN], - is_session_token=True, - client_session=async_get_clientsession(hass), - ) + await nintendo_auth.async_complete_login(use_session_token=True) except (InvalidSessionTokenException, InvalidOAuthConfigurationException) as err: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, diff --git a/homeassistant/components/nintendo_parental_controls/config_flow.py b/homeassistant/components/nintendo_parental_controls/config_flow.py index 5b1532057122a..936bc1772c035 100644 --- a/homeassistant/components/nintendo_parental_controls/config_flow.py +++ b/homeassistant/components/nintendo_parental_controls/config_flow.py @@ -6,9 +6,9 @@ import logging from typing import TYPE_CHECKING, Any +from pynintendoauth.exceptions import HttpException, InvalidSessionTokenException from pynintendoparental import Authenticator from pynintendoparental.api import Api -from pynintendoparental.exceptions import HttpException, InvalidSessionTokenException import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -33,18 +33,14 @@ async def async_step_user( """Handle the initial step.""" errors = {} if self.auth is None: - self.auth = Authenticator.generate_login( - client_session=async_get_clientsession(self.hass) - ) + self.auth = Authenticator(client_session=async_get_clientsession(self.hass)) if user_input is not None: nintendo_api = Api( self.auth, self.hass.config.time_zone, self.hass.config.language ) try: - await self.auth.complete_login( - self.auth, user_input[CONF_API_TOKEN], False - ) + await self.auth.async_complete_login(user_input[CONF_API_TOKEN]) except (ValueError, InvalidSessionTokenException, HttpException): errors["base"] = "invalid_auth" else: @@ -67,7 +63,7 @@ async def async_step_user( return self.async_create_entry( title=self.auth.account_id, data={ - CONF_SESSION_TOKEN: self.auth.get_session_token, + CONF_SESSION_TOKEN: self.auth.session_token, }, ) return self.async_show_form( @@ -90,14 +86,10 @@ async def async_step_reauth_confirm( errors: dict[str, str] = {} reauth_entry = self._get_reauth_entry() if self.auth is None: - self.auth = Authenticator.generate_login( - client_session=async_get_clientsession(self.hass) - ) + self.auth = Authenticator(client_session=async_get_clientsession(self.hass)) if user_input is not None: try: - await self.auth.complete_login( - self.auth, user_input[CONF_API_TOKEN], False - ) + await self.auth.async_complete_login(user_input[CONF_API_TOKEN]) except (ValueError, InvalidSessionTokenException, HttpException): errors["base"] = "invalid_auth" else: @@ -105,7 +97,7 @@ async def async_step_reauth_confirm( reauth_entry, data={ **reauth_entry.data, - CONF_SESSION_TOKEN: self.auth.get_session_token, + CONF_SESSION_TOKEN: self.auth.session_token, }, ) return self.async_show_form( diff --git a/homeassistant/components/nintendo_parental_controls/coordinator.py b/homeassistant/components/nintendo_parental_controls/coordinator.py index aef2549180cfb..0b55db7ea03ce 100644 --- a/homeassistant/components/nintendo_parental_controls/coordinator.py +++ b/homeassistant/components/nintendo_parental_controls/coordinator.py @@ -5,11 +5,9 @@ from datetime import timedelta import logging +from pynintendoauth.exceptions import InvalidOAuthConfigurationException from pynintendoparental import Authenticator, NintendoParental -from pynintendoparental.exceptions import ( - InvalidOAuthConfigurationException, - NoDevicesFoundException, -) +from pynintendoparental.exceptions import NoDevicesFoundException from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/nintendo_parental_controls/manifest.json b/homeassistant/components/nintendo_parental_controls/manifest.json index a4743251426d6..53c16fa936115 100644 --- a/homeassistant/components/nintendo_parental_controls/manifest.json +++ b/homeassistant/components/nintendo_parental_controls/manifest.json @@ -5,7 +5,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nintendo_parental_controls", "iot_class": "cloud_polling", - "loggers": ["pynintendoparental"], + "loggers": ["pynintendoauth", "pynintendoparental"], "quality_scale": "bronze", - "requirements": ["pynintendoparental==1.1.3"] + "requirements": ["pynintendoauth==1.0.2", "pynintendoparental==2.1.3"] } diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py index d539243d28739..c83c71ee9bc9a 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -37,7 +37,6 @@ class PlugwiseSelectEntityDescription(SelectEntityDescription): PlugwiseSelectEntityDescription( key=SELECT_SCHEDULE, translation_key=SELECT_SCHEDULE, - entity_category=EntityCategory.CONFIG, options_key="available_schedules", ), PlugwiseSelectEntityDescription( diff --git a/homeassistant/components/remote_calendar/calendar.py b/homeassistant/components/remote_calendar/calendar.py index 7009a8af360e9..86a49e6b0c6fa 100644 --- a/homeassistant/components/remote_calendar/calendar.py +++ b/homeassistant/components/remote_calendar/calendar.py @@ -4,7 +4,6 @@ import logging from ical.event import Event -from ical.timeline import Timeline from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.core import HomeAssistant @@ -49,18 +48,12 @@ def __init__( super().__init__(coordinator) self._attr_name = entry.data[CONF_CALENDAR_NAME] self._attr_unique_id = entry.entry_id - self._timeline: Timeline | None = None + self._event: CalendarEvent | None = None @property def event(self) -> CalendarEvent | None: """Return the next upcoming event.""" - if self._timeline is None: - return None - now = dt_util.now() - events = self._timeline.active_after(now) - if event := next(events, None): - return _get_calendar_event(event) - return None + return self._event async def async_get_events( self, hass: HomeAssistant, start_date: datetime, end_date: datetime @@ -86,12 +79,14 @@ async def async_update(self) -> None: """ await super().async_update() - def _get_timeline() -> Timeline | None: - """Return the next active event.""" + def next_event() -> CalendarEvent | None: now = dt_util.now() - return self.coordinator.data.timeline_tz(now.tzinfo) + events = self.coordinator.data.timeline_tz(now.tzinfo).active_after(now) + if event := next(events, None): + return _get_calendar_event(event) + return None - self._timeline = await self.hass.async_add_executor_job(_get_timeline) + self._event = await self.hass.async_add_executor_job(next_event) def _get_calendar_event(event: Event) -> CalendarEvent: diff --git a/homeassistant/components/remote_calendar/manifest.json b/homeassistant/components/remote_calendar/manifest.json index 6286e5548a8fb..807033c6d3591 100644 --- a/homeassistant/components/remote_calendar/manifest.json +++ b/homeassistant/components/remote_calendar/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["ical"], "quality_scale": "silver", - "requirements": ["ical==12.1.1"] + "requirements": ["ical==12.1.2"] } diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 0a4c88124e8d2..5fbe1ba39512e 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -4,8 +4,9 @@ import asyncio from collections.abc import Callable -from datetime import timedelta +from datetime import UTC, datetime, timedelta import logging +from random import uniform from time import time from typing import Any @@ -34,6 +35,7 @@ BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL, CONF_BC_ONLY, CONF_BC_PORT, + CONF_FIRMWARE_CHECK_TIME, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN, @@ -212,15 +214,41 @@ async def async_check_firmware_update() -> None: config_entry=config_entry, name=f"reolink.{host.api.nvr_name}.firmware", update_method=async_check_firmware_update, - update_interval=FIRMWARE_UPDATE_INTERVAL, + update_interval=None, # Do not fetch data automatically, resume 24h schedule ) + async def first_firmware_check(*args: Any) -> None: + """Start first firmware check delayed to continue 24h schedule.""" + firmware_coordinator.update_interval = FIRMWARE_UPDATE_INTERVAL + await firmware_coordinator.async_refresh() + host.cancel_first_firmware_check = None + + # get update time from config entry + check_time_sec = config_entry.data.get(CONF_FIRMWARE_CHECK_TIME) + if check_time_sec is None: + check_time_sec = uniform(0, 86400) + data = { + **config_entry.data, + CONF_FIRMWARE_CHECK_TIME: check_time_sec, + } + hass.config_entries.async_update_entry(config_entry, data=data) + # If camera WAN blocked, firmware check fails and takes long, do not prevent setup - config_entry.async_create_background_task( - hass, - firmware_coordinator.async_refresh(), - f"Reolink firmware check {config_entry.entry_id}", + now = datetime.now(UTC) + check_time = timedelta(seconds=check_time_sec) + delta_midnight = now - now.replace(hour=0, minute=0, second=0, microsecond=0) + firmware_check_delay = check_time - delta_midnight + if firmware_check_delay < timedelta(0): + firmware_check_delay += timedelta(days=1) + _LOGGER.debug( + "Scheduling first Reolink %s firmware check in %s", + host.api.nvr_name, + firmware_check_delay, ) + host.cancel_first_firmware_check = async_call_later( + hass, firmware_check_delay, first_firmware_check + ) + # Fetch initial data so we have data when entities subscribe try: await device_coordinator.async_config_entry_first_refresh() @@ -312,6 +340,8 @@ async def async_unload_entry( host.api.baichuan.unregister_callback(f"camera_{channel}_wake") if host.cancel_refresh_privacy_mode is not None: host.cancel_refresh_privacy_mode() + if host.cancel_first_firmware_check is not None: + host.cancel_first_firmware_check() return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/reolink/const.py b/homeassistant/components/reolink/const.py index db2d105984be9..59d594a5406e0 100644 --- a/homeassistant/components/reolink/const.py +++ b/homeassistant/components/reolink/const.py @@ -6,6 +6,7 @@ CONF_BC_PORT = "baichuan_port" CONF_BC_ONLY = "baichuan_only" CONF_SUPPORTS_PRIVACY_MODE = "privacy_mode_supported" +CONF_FIRMWARE_CHECK_TIME = "firmware_check_time" # Conserve battery by not waking the battery cameras each minute during normal update # Most props are cached in the Home Hub and updated, but some are skipped diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 57af24043219c..7b7cc48c1dddc 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -130,6 +130,7 @@ def get_aiohttp_session() -> aiohttp.ClientSession: self._lost_subscription_start: bool = False self._lost_subscription: bool = False self.cancel_refresh_privacy_mode: CALLBACK_TYPE | None = None + self.cancel_first_firmware_check: CALLBACK_TYPE | None = None @callback def async_register_update_cmd(self, cmd: str, channel: int | None = None) -> None: diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 5d67233f7bc7d..a63fa0e65c105 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -18,6 +18,7 @@ from roborock.devices.device import RoborockDevice from roborock.devices.device_manager import UserParams, create_device_manager from roborock.map.map_parser import MapParserConfig +from roborock.mqtt.session import MqttSessionUnauthorized from homeassistant.const import CONF_USERNAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant @@ -92,6 +93,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> translation_domain=DOMAIN, translation_key="no_user_agreement", ) from err + except MqttSessionUnauthorized as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="mqtt_unauthorized", + ) from err except RoborockException as err: _LOGGER.debug("Failed to get Roborock home data: %s", err) raise ConfigEntryNotReady( diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 844323ca6f3dd..baf1973cc0068 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -43,6 +43,12 @@ SCAN_INTERVAL = timedelta(seconds=30) +# Roborock devices have a known issue where they go offline for a short period +# around 3AM local time for ~1 minute and reset both the local connection +# and MQTT connection. To avoid log spam, we will avoid reporting failures refreshing +# data until this duration has passed. +MIN_UNAVAILABLE_DURATION = timedelta(minutes=2) + _LOGGER = logging.getLogger(__name__) @@ -102,6 +108,9 @@ def __init__( # Keep track of last attempt to refresh maps/rooms to know when to try again. self._last_home_update_attempt: datetime self.last_home_update: datetime | None = None + # Tracks the last successful update to control when we report failure + # to the base class. This is reset on successful data update. + self._last_update_success_time: datetime | None = None @cached_property def dock_device_info(self) -> DeviceInfo: @@ -169,7 +178,7 @@ async def update_map(self) -> None: self.last_home_update = dt_util.utcnow() async def _verify_api(self) -> None: - """Verify that the api is reachable. If it is not, switch clients.""" + """Verify that the api is reachable.""" if self._device.is_connected: if self._device.is_local_connected: async_delete_issue( @@ -217,26 +226,27 @@ async def _async_update_data(self) -> DeviceState: try: # Update device props and standard api information await self._update_device_prop() - - # If the vacuum is currently cleaning and it has been IMAGE_CACHE_INTERVAL - # since the last map update, you can update the map. - new_status = self.properties_api.status - if ( - new_status.in_cleaning - and (dt_util.utcnow() - self._last_home_update_attempt) - > IMAGE_CACHE_INTERVAL - ) or self.last_update_state != new_status.state_name: - self._last_home_update_attempt = dt_util.utcnow() - try: - await self.update_map() - except HomeAssistantError as err: - _LOGGER.debug("Failed to update map: %s", err) - except RoborockException as ex: - _LOGGER.debug("Failed to update data: %s", ex) - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="update_data_fail", - ) from ex + except UpdateFailed: + if self._should_suppress_update_failure(): + _LOGGER.debug( + "Suppressing update failure until unavailable duration passed" + ) + return self.data + raise + + # If the vacuum is currently cleaning and it has been IMAGE_CACHE_INTERVAL + # since the last map update, you can update the map. + new_status = self.properties_api.status + if ( + new_status.in_cleaning + and (dt_util.utcnow() - self._last_home_update_attempt) + > IMAGE_CACHE_INTERVAL + ) or self.last_update_state != new_status.state_name: + self._last_home_update_attempt = dt_util.utcnow() + try: + await self.update_map() + except HomeAssistantError as err: + _LOGGER.debug("Failed to update map: %s", err) if self.properties_api.status.in_cleaning: if self._device.is_local_connected: @@ -248,6 +258,8 @@ async def _async_update_data(self) -> DeviceState: else: self.update_interval = V1_CLOUD_NOT_CLEANING_INTERVAL self.last_update_state = self.properties_api.status.state_name + self._last_update_success_time = dt_util.utcnow() + _LOGGER.debug("Data update successful %s", self._last_update_success_time) return DeviceState( status=self.properties_api.status, dnd_timer=self.properties_api.dnd, @@ -255,6 +267,23 @@ async def _async_update_data(self) -> DeviceState: clean_summary=self.properties_api.clean_summary, ) + def _should_suppress_update_failure(self) -> bool: + """Determine if we should suppress update failure reporting. + + We suppress reporting update failures until a minimum duration has + passed since the last successful update. This is used to avoid reporting + the device as unavailable for short periods, a known issue. + + The intent is to apply to routine background state refreshes and not + other failures such as the first update or map updates. + """ + if self._last_update_success_time is None: + # Never had a successful update, do not suppress + return False + failure_duration = dt_util.utcnow() - self._last_update_success_time + _LOGGER.debug("Update failure duration: %s", failure_duration) + return failure_duration < MIN_UNAVAILABLE_DURATION + async def get_routines(self) -> list[HomeDataScene]: """Get routines.""" try: diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 9178d58eb683a..993081f8049c4 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -20,7 +20,7 @@ "loggers": ["roborock"], "quality_scale": "silver", "requirements": [ - "python-roborock==3.12.2", + "python-roborock==3.19.0", "vacuum-map-parser-roborock==0.1.4" ] } diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 2dba430fde93d..bd376c03255af 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -73,7 +73,9 @@ class RoborockSelectDescription(SelectEntityDescription): key="dust_collection_mode", translation_key="dust_collection_mode", api_command=RoborockCommand.SET_DUST_COLLECTION_MODE, - value_fn=lambda api: api.dust_collection_mode.mode.name, # type: ignore[union-attr] + value_fn=lambda api: ( + mode.name if (mode := api.dust_collection_mode.mode) is not None else None # type: ignore[union-attr] + ), entity_category=EntityCategory.CONFIG, options_lambda=lambda api: ( RoborockDockDustCollectionModeCode.keys() diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 3c08b9e985dc3..23e9d9dd44b60 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -424,6 +424,9 @@ "map_failure": { "message": "Something went wrong creating the map" }, + "mqtt_unauthorized": { + "message": "Roborock MQTT servers rejected the connection due to rate limiting or invalid credentials. You may either attempt to reauthenticate or wait and reload the integration." + }, "no_coordinators": { "message": "No devices were able to successfully setup" }, diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index b3cc5fe0263ce..268bd43b7dbd1 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -40,7 +40,7 @@ "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.7.2", "wakeonlan==3.1.0", - "async-upnp-client==0.46.0" + "async-upnp-client==0.46.1" ], "ssdp": [ { diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 6be47bb940884..55f21599290f2 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -537,6 +537,12 @@ "voltmeter_value": { "name": "Voltmeter value" }, + "voltmeter_value_with_channel_name": { + "name": "{channel_name} voltmeter value" + }, + "voltmeter_with_channel_name": { + "name": "{channel_name} voltmeter" + }, "water_consumption": { "name": "Water consumption" }, diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index 1458e0186556d..31defde5fa561 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -12,7 +12,7 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "silver", - "requirements": ["pysmlight==0.2.11"], + "requirements": ["pysmlight==0.2.13"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index bcc252a7d8d72..146a18555e275 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -12,7 +12,7 @@ "quality_scale": "bronze", "requirements": [ "defusedxml==0.7.1", - "soco==0.30.12", + "soco==0.30.13", "sonos-websocket==0.1.3" ], "ssdp": [ diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index c61f047d3e386..ed2d4add7ba94 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -72,6 +72,7 @@ NEVER_TIME = -1200.0 RESUB_COOLDOWN_SECONDS = 10.0 +WAIT_FOR_GROUPS_TIMEOUT = 30.0 EVENT_CHARGING = { "CHARGING": True, "NOT_CHARGING": False, @@ -690,7 +691,8 @@ async def _async_check_activity(self) -> None: async def async_offline(self) -> None: """Handle removal of speaker when unavailable.""" - assert self._subscription_lock is not None + if not self._subscription_lock: + self._subscription_lock = asyncio.Lock() async with self._subscription_lock: await self._async_offline() @@ -1014,11 +1016,21 @@ async def join_multi( speakers: list[SonosSpeaker], ) -> None: """Form a group with other players.""" + # When joining multiple speakers, build the group incrementally and + # wait for the grouping to complete after each join. This avoids race + # conditions in zone topology updates. async with config_entry.runtime_data.topology_condition: - group: list[SonosSpeaker] = await hass.async_add_executor_job( - master.join, speakers - ) - await SonosSpeaker.wait_for_groups(hass, config_entry, [group]) + join_list: list[SonosSpeaker] = [] + for speaker in speakers: + _LOGGER.debug("Join %s to %s", speaker.zone_name, master.zone_name) + join_list.append(speaker) + group: list[SonosSpeaker] = await hass.async_add_executor_job( + master.join, join_list + ) + await SonosSpeaker.wait_for_groups(hass, config_entry, [group]) + _LOGGER.debug( + "Join Complete %s to %s", speaker.zone_name, master.zone_name + ) @soco_error() def unjoin(self) -> None: @@ -1212,7 +1224,7 @@ def _test_groups(groups: list[list[SonosSpeaker]]) -> bool: return True try: - async with asyncio.timeout(5): + async with asyncio.timeout(WAIT_FOR_GROUPS_TIMEOUT): while not _test_groups(groups): await config_entry.runtime_data.topology_condition.wait() except TimeoutError: diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 6ae7d8275da75..61015e9580906 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["async_upnp_client"], "quality_scale": "internal", - "requirements": ["async-upnp-client==0.46.0"] + "requirements": ["async-upnp-client==0.46.1"] } diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index 574c5399ff826..c057ae0c21472 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -13,12 +13,12 @@ ConnectionErrorException, DataMissingException, ) +from systembridgeconnector.models.keyboard_key import KeyboardKey +from systembridgeconnector.models.keyboard_text import KeyboardText +from systembridgeconnector.models.modules.processes import Process +from systembridgeconnector.models.open_path import OpenPath +from systembridgeconnector.models.open_url import OpenUrl from systembridgeconnector.version import Version -from systembridgemodels.keyboard_key import KeyboardKey -from systembridgemodels.keyboard_text import KeyboardText -from systembridgemodels.modules.processes import Process -from systembridgemodels.open_path import OpenPath -from systembridgemodels.open_url import OpenUrl import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/system_bridge/config_flow.py b/homeassistant/components/system_bridge/config_flow.py index bf6057a27bb1f..6bf001c96037d 100644 --- a/homeassistant/components/system_bridge/config_flow.py +++ b/homeassistant/components/system_bridge/config_flow.py @@ -12,8 +12,8 @@ ConnectionClosedException, ConnectionErrorException, ) +from systembridgeconnector.models.modules import GetData, Module from systembridgeconnector.websocket_client import WebSocketClient -from systembridgemodels.modules import GetData, Module import voluptuous as vol from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult diff --git a/homeassistant/components/system_bridge/const.py b/homeassistant/components/system_bridge/const.py index 235d7e6b9869d..ae25f80f45533 100644 --- a/homeassistant/components/system_bridge/const.py +++ b/homeassistant/components/system_bridge/const.py @@ -2,7 +2,7 @@ from typing import Final -from systembridgemodels.modules import Module +from systembridgeconnector.models.modules import Module DOMAIN = "system_bridge" diff --git a/homeassistant/components/system_bridge/coordinator.py b/homeassistant/components/system_bridge/coordinator.py index f665c88121c70..6fca2e5902fbf 100644 --- a/homeassistant/components/system_bridge/coordinator.py +++ b/homeassistant/components/system_bridge/coordinator.py @@ -13,13 +13,13 @@ ConnectionClosedException, ConnectionErrorException, ) -from systembridgeconnector.websocket_client import WebSocketClient -from systembridgemodels.modules import ( +from systembridgeconnector.models.modules import ( GetData, Module, ModulesData, RegisterDataListener, ) +from systembridgeconnector.websocket_client import WebSocketClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( diff --git a/homeassistant/components/system_bridge/data.py b/homeassistant/components/system_bridge/data.py index f07e8d75f285f..983b16a20d430 100644 --- a/homeassistant/components/system_bridge/data.py +++ b/homeassistant/components/system_bridge/data.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field -from systembridgemodels.modules import ( +from systembridgeconnector.models.modules import ( CPU, GPU, Battery, diff --git a/homeassistant/components/system_bridge/manifest.json b/homeassistant/components/system_bridge/manifest.json index d2d9bb6e65725..cefd5c3520f8c 100644 --- a/homeassistant/components/system_bridge/manifest.json +++ b/homeassistant/components/system_bridge/manifest.json @@ -9,6 +9,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["systembridgeconnector"], - "requirements": ["systembridgeconnector==5.1.0"], + "requirements": ["systembridgeconnector==5.2.4"], "zeroconf": ["_system-bridge._tcp.local."] } diff --git a/homeassistant/components/system_bridge/media_player.py b/homeassistant/components/system_bridge/media_player.py index 2be2f06c1e776..c7b1fab679a70 100644 --- a/homeassistant/components/system_bridge/media_player.py +++ b/homeassistant/components/system_bridge/media_player.py @@ -5,7 +5,7 @@ import datetime as dt from typing import Final -from systembridgemodels.media_control import MediaAction, MediaControl +from systembridgeconnector.models.media_control import MediaAction, MediaControl from homeassistant.components.media_player import ( MediaPlayerDeviceClass, diff --git a/homeassistant/components/system_bridge/media_source.py b/homeassistant/components/system_bridge/media_source.py index 53bc4f3250628..930557568b83a 100644 --- a/homeassistant/components/system_bridge/media_source.py +++ b/homeassistant/components/system_bridge/media_source.py @@ -2,9 +2,9 @@ from __future__ import annotations -from systembridgemodels.media_directories import MediaDirectory -from systembridgemodels.media_files import MediaFile, MediaFiles -from systembridgemodels.media_get_files import MediaGetFiles +from systembridgeconnector.models.media_directories import MediaDirectory +from systembridgeconnector.models.media_files import MediaFile, MediaFiles +from systembridgeconnector.models.media_get_files import MediaGetFiles from homeassistant.components.media_player import MediaClass from homeassistant.components.media_source import ( @@ -183,9 +183,9 @@ def _build_media_items( for file in media_files.files if file.is_directory or ( - file.is_file - and file.mime_type is not None - and file.mime_type.startswith(MEDIA_MIME_TYPES) + not file.is_directory + and file.content_type is not None + and file.content_type.startswith(MEDIA_MIME_TYPES) ) ], ) @@ -197,20 +197,20 @@ def _build_media_item( ) -> BrowseMediaSource: """Build individual media item.""" ext = "" - if media_file.is_file and media_file.mime_type is not None: - ext = f"~~{media_file.mime_type}" + if not media_file.is_directory and media_file.content_type is not None: + ext = f"~~{media_file.content_type}" - if media_file.is_directory or media_file.mime_type is None: + if media_file.is_directory or media_file.content_type is None: media_class = MediaClass.DIRECTORY else: - media_class = MEDIA_CLASS_MAP[media_file.mime_type.split("/", 1)[0]] + media_class = MEDIA_CLASS_MAP[media_file.content_type.split("/", 1)[0]] return BrowseMediaSource( domain=DOMAIN, identifier=f"{path}/{media_file.name}{ext}", media_class=media_class, - media_content_type=media_file.mime_type, + media_content_type=media_file.content_type, title=media_file.name, - can_play=media_file.is_file, + can_play=not media_file.is_directory, can_expand=media_file.is_directory, ) diff --git a/homeassistant/components/system_bridge/notify.py b/homeassistant/components/system_bridge/notify.py index 0e2f058cc7c6f..2b13fef071e11 100644 --- a/homeassistant/components/system_bridge/notify.py +++ b/homeassistant/components/system_bridge/notify.py @@ -5,7 +5,7 @@ import logging from typing import Any -from systembridgemodels.notification import Notification +from systembridgeconnector.models.notification import Notification from homeassistant.components.notify import ( ATTR_DATA, diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index f07c96fe8caad..7a7f2c555df1b 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -7,9 +7,9 @@ from datetime import UTC, datetime, timedelta from typing import Final, cast -from systembridgemodels.modules.cpu import PerCPU -from systembridgemodels.modules.displays import Display -from systembridgemodels.modules.gpus import GPU +from systembridgeconnector.models.modules.cpu import PerCPU +from systembridgeconnector.models.modules.displays import Display +from systembridgeconnector.models.modules.gpus import GPU from homeassistant.components.sensor import ( SensorDeviceClass, diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 9211356a27ad8..67033f058da27 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.46.0", "getmac==0.9.5"], + "requirements": ["async-upnp-client==0.46.1", "getmac==0.9.5"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/homeassistant/components/volvo/coordinator.py b/homeassistant/components/volvo/coordinator.py index d42ca6e9c94c8..43057c1e141a5 100644 --- a/homeassistant/components/volvo/coordinator.py +++ b/homeassistant/components/volvo/coordinator.py @@ -297,9 +297,9 @@ def __init__( async def _async_determine_api_calls( self, ) -> list[Callable[[], Coroutine[Any, Any, Any]]]: - api_calls: list[Any] = [] api = self.context.api vehicle = self.context.vehicle + api_calls: list[Any] = [api.async_get_engine_status] if vehicle.has_battery_engine(): capabilities = await api.async_get_energy_capabilities() @@ -317,9 +317,6 @@ def _normalize_key(key: str) -> str: api_calls.append(self._async_get_energy_state) - if vehicle.has_combustion_engine(): - api_calls.append(api.async_get_engine_status) - return api_calls async def _async_get_energy_state( diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index e09e176e46ef7..6968bd9214347 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -16,7 +16,7 @@ }, "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], - "requirements": ["yeelight==0.7.16", "async-upnp-client==0.46.0"], + "requirements": ["yeelight==0.7.16", "async-upnp-client==0.46.1"], "zeroconf": [ { "name": "yeelink-*", diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index e78de0d92f765..41e1c8ae914f8 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -22,7 +22,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["zha==0.0.80"], + "requirements": ["zha==0.0.81"], "usb": [ { "description": "*2652*", diff --git a/homeassistant/const.py b/homeassistant/const.py index f925788648c33..061820a2a84e6 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -17,7 +17,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 12 -PATCH_VERSION: Final = "3" +PATCH_VERSION: Final = "4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7a4a386348e78..8f4b795d8e2fd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -2,7 +2,7 @@ aiodhcpwatcher==1.2.1 aiodiscover==2.7.1 -aiodns==3.5.0 +aiodns==3.6.1 aiohasupervisor==0.3.3 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.3.0 @@ -13,7 +13,7 @@ aiozoneinfo==0.2.3 annotatedyaml==1.0.2 astral==2.2 async-interrupt==1.2.2 -async-upnp-client==0.46.0 +async-upnp-client==0.46.1 atomicwrites-homeassistant==1.4.1 attrs==25.4.0 audioop-lts==0.2.1 @@ -39,7 +39,7 @@ habluetooth==5.7.0 hass-nabucasa==1.7.0 hassil==3.5.0 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20251203.2 +home-assistant-frontend==20251203.3 home-assistant-intents==2025.12.2 httpx==0.28.1 ifaddr==0.2.0 diff --git a/pyproject.toml b/pyproject.toml index a15b883986187..9fd249f245008 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.12.3" +version = "2025.12.4" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." @@ -24,7 +24,7 @@ classifiers = [ ] requires-python = ">=3.13.2" dependencies = [ - "aiodns==3.5.0", + "aiodns==3.6.1", # Integrations may depend on hassio integration without listing it to # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 diff --git a/requirements.txt b/requirements.txt index 5500bb807dae0..0aab06b448633 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ -c homeassistant/package_constraints.txt # Home Assistant Core -aiodns==3.5.0 +aiodns==3.6.1 aiohasupervisor==0.3.3 aiohttp==3.13.2 aiohttp_cors==0.8.1 diff --git a/requirements_all.txt b/requirements_all.txt index d5e48cdc54df7..136f3483e2953 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -206,7 +206,7 @@ aioaquacell==0.2.0 aioaseko==1.0.0 # homeassistant.components.asuswrt -aioasuswrt==1.5.2 +aioasuswrt==1.5.4 # homeassistant.components.husqvarna_automower aioautomower==2.7.1 @@ -231,7 +231,7 @@ aiodhcpwatcher==1.2.1 aiodiscover==2.7.1 # homeassistant.components.dnsip -aiodns==3.5.0 +aiodns==3.6.1 # homeassistant.components.duke_energy aiodukeenergy==0.3.0 @@ -319,7 +319,7 @@ aiolookin==1.0.0 aiolyric==2.0.2 # homeassistant.components.mealie -aiomealie==1.1.0 +aiomealie==1.1.1 # homeassistant.components.modern_forms aiomodernforms==0.1.8 @@ -545,7 +545,7 @@ asusrouter==1.21.3 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.46.0 +async-upnp-client==0.46.1 # homeassistant.components.arve asyncarve==0.1.1 @@ -648,7 +648,7 @@ bleak==1.0.1 blebox-uniapi==2.5.0 # homeassistant.components.blink -blinkpy==0.25.1 +blinkpy==0.25.2 # homeassistant.components.bitcoin blockchain==1.4.4 @@ -854,7 +854,7 @@ ecoaliface==0.4.0 eheimdigital==1.4.0 # homeassistant.components.ekeybionyx -ekey-bionyxpy==1.0.0 +ekey-bionyxpy==1.0.1 # homeassistant.components.electric_kiwi electrickiwi-api==0.9.14 @@ -1198,7 +1198,7 @@ hole==0.9.0 holidays==0.84 # homeassistant.components.frontend -home-assistant-frontend==20251203.2 +home-assistant-frontend==20251203.3 # homeassistant.components.conversation home-assistant-intents==2025.12.2 @@ -1234,7 +1234,7 @@ ibeacon-ble==1.2.0 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==12.1.1 +ical==12.1.2 # homeassistant.components.caldav icalendar==6.3.1 @@ -2220,7 +2220,10 @@ pynetio==0.1.9.1 pynina==0.3.6 # homeassistant.components.nintendo_parental_controls -pynintendoparental==1.1.3 +pynintendoauth==1.0.2 + +# homeassistant.components.nintendo_parental_controls +pynintendoparental==2.1.3 # homeassistant.components.nobo_hub pynobo==1.8.1 @@ -2285,7 +2288,7 @@ pypaperless==4.1.1 pypca==0.0.7 # homeassistant.components.lcn -pypck==0.9.7 +pypck==0.9.8 # homeassistant.components.pglab pypglab==0.0.5 @@ -2412,7 +2415,7 @@ pysmhi==1.1.0 pysml==0.1.5 # homeassistant.components.smlight -pysmlight==0.2.11 +pysmlight==0.2.13 # homeassistant.components.snmp pysnmp==7.1.22 @@ -2557,7 +2560,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==3.12.2 +python-roborock==3.19.0 # homeassistant.components.smarttub python-smarttub==0.0.45 @@ -2859,7 +2862,7 @@ smart-meter-texas==0.5.5 snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.12 +soco==0.30.13 # homeassistant.components.solaredge_local solaredge-local==0.2.3 @@ -2931,7 +2934,7 @@ switchbot-api==2.8.0 synology-srm==0.2.0 # homeassistant.components.system_bridge -systembridgeconnector==5.1.0 +systembridgeconnector==5.2.4 # homeassistant.components.tailscale tailscale==0.6.2 @@ -3246,7 +3249,7 @@ zeroconf==0.148.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.80 +zha==0.0.81 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ce513b1e4cc0b..0d3854cbba0d5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -197,7 +197,7 @@ aioaquacell==0.2.0 aioaseko==1.0.0 # homeassistant.components.asuswrt -aioasuswrt==1.5.2 +aioasuswrt==1.5.4 # homeassistant.components.husqvarna_automower aioautomower==2.7.1 @@ -222,7 +222,7 @@ aiodhcpwatcher==1.2.1 aiodiscover==2.7.1 # homeassistant.components.dnsip -aiodns==3.5.0 +aiodns==3.6.1 # homeassistant.components.duke_energy aiodukeenergy==0.3.0 @@ -304,7 +304,7 @@ aiolookin==1.0.0 aiolyric==2.0.2 # homeassistant.components.mealie -aiomealie==1.1.0 +aiomealie==1.1.1 # homeassistant.components.modern_forms aiomodernforms==0.1.8 @@ -512,7 +512,7 @@ asusrouter==1.21.3 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.46.0 +async-upnp-client==0.46.1 # homeassistant.components.arve asyncarve==0.1.1 @@ -585,7 +585,7 @@ bleak==1.0.1 blebox-uniapi==2.5.0 # homeassistant.components.blink -blinkpy==0.25.1 +blinkpy==0.25.2 # homeassistant.components.blue_current bluecurrent-api==1.3.2 @@ -754,7 +754,7 @@ easyenergy==2.1.2 eheimdigital==1.4.0 # homeassistant.components.ekeybionyx -ekey-bionyxpy==1.0.0 +ekey-bionyxpy==1.0.1 # homeassistant.components.electric_kiwi electrickiwi-api==0.9.14 @@ -1056,7 +1056,7 @@ hole==0.9.0 holidays==0.84 # homeassistant.components.frontend -home-assistant-frontend==20251203.2 +home-assistant-frontend==20251203.3 # homeassistant.components.conversation home-assistant-intents==2025.12.2 @@ -1086,7 +1086,7 @@ ibeacon-ble==1.2.0 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==12.1.1 +ical==12.1.2 # homeassistant.components.caldav icalendar==6.3.1 @@ -1864,7 +1864,10 @@ pynetgear==0.10.10 pynina==0.3.6 # homeassistant.components.nintendo_parental_controls -pynintendoparental==1.1.3 +pynintendoauth==1.0.2 + +# homeassistant.components.nintendo_parental_controls +pynintendoparental==2.1.3 # homeassistant.components.nobo_hub pynobo==1.8.1 @@ -1920,7 +1923,7 @@ pypalazzetti==0.1.20 pypaperless==4.1.1 # homeassistant.components.lcn -pypck==0.9.7 +pypck==0.9.8 # homeassistant.components.pglab pypglab==0.0.5 @@ -2026,7 +2029,7 @@ pysmhi==1.1.0 pysml==0.1.5 # homeassistant.components.smlight -pysmlight==0.2.11 +pysmlight==0.2.13 # homeassistant.components.snmp pysnmp==7.1.22 @@ -2135,7 +2138,7 @@ python-pooldose==0.7.8 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==3.12.2 +python-roborock==3.19.0 # homeassistant.components.smarttub python-smarttub==0.0.45 @@ -2380,7 +2383,7 @@ smart-meter-texas==0.5.5 snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.12 +soco==0.30.13 # homeassistant.components.solaredge solaredge-web==0.0.1 @@ -2440,7 +2443,7 @@ surepy==0.9.0 switchbot-api==2.8.0 # homeassistant.components.system_bridge -systembridgeconnector==5.1.0 +systembridgeconnector==5.2.4 # homeassistant.components.tailscale tailscale==0.6.2 @@ -2701,7 +2704,7 @@ zeroconf==0.148.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.80 +zha==0.0.81 # homeassistant.components.zwave_js zwave-js-server-python==0.67.1 diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index 4cf6b2cc5f739..7fe70117db36a 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -577,6 +577,26 @@ async def test_cannot_deactive_owner(mock_hass) -> None: await manager.async_deactivate_user(owner) +async def test_deactivate_user_removes_refresh_tokens(hass: HomeAssistant) -> None: + """Test that deactivating a user removes their refresh tokens.""" + manager = await auth.auth_manager_from_config(hass, [], []) + user = MockUser().add_to_auth_manager(manager) + + refresh_token1 = await manager.async_create_refresh_token(user, CLIENT_ID) + refresh_token2 = await manager.async_create_refresh_token(user, "other-client") + assert len(user.refresh_tokens) == 2 + assert manager.async_get_refresh_token(refresh_token1.id) == refresh_token1 + assert manager.async_get_refresh_token(refresh_token2.id) == refresh_token2 + + await manager.async_deactivate_user(user) + + # Verify user is deactivated and all refresh tokens are removed + assert user.is_active is False + assert len(user.refresh_tokens) == 0 + assert manager.async_get_refresh_token(refresh_token1.id) is None + assert manager.async_get_refresh_token(refresh_token2.id) is None + + async def test_remove_refresh_token(hass: HomeAssistant) -> None: """Test that we can remove a refresh token.""" manager = await auth.auth_manager_from_config(hass, [], []) diff --git a/tests/components/config/test_area_registry.py b/tests/components/config/test_area_registry.py index 2421a7d10c5c4..92458aff10035 100644 --- a/tests/components/config/test_area_registry.py +++ b/tests/components/config/test_area_registry.py @@ -172,6 +172,39 @@ async def test_create_area( } assert len(area_registry.areas) == 2 + # Create area with invalid aliases + await client.send_json_auto_id( + { + "aliases": [" alias_1 ", "", " "], + "floor_id": "first_floor", + "icon": "mdi:garage", + "labels": ["label_1", "label_2"], + "name": "mock 3", + "picture": "/image/example.png", + "temperature_entity_id": "sensor.mock_temperature", + "humidity_entity_id": "sensor.mock_humidity", + "type": "config/area_registry/create", + } + ) + + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"] == { + "aliases": unordered(["alias_1"]), + "area_id": ANY, + "floor_id": "first_floor", + "icon": "mdi:garage", + "labels": unordered(["label_1", "label_2"]), + "name": "mock 3", + "picture": "/image/example.png", + "created_at": utcnow().timestamp(), + "modified_at": utcnow().timestamp(), + "temperature_entity_id": "sensor.mock_temperature", + "humidity_entity_id": "sensor.mock_humidity", + } + assert len(area_registry.areas) == 3 + async def test_create_area_with_name_already_in_use( client: MockHAClientWebSocket, area_registry: ar.AreaRegistry @@ -304,6 +337,40 @@ async def test_update_area( } assert len(area_registry.areas) == 1 + modified_at = datetime.fromisoformat("2024-07-16T13:55:00.900075+00:00") + freezer.move_to(modified_at) + + await client.send_json_auto_id( + { + "type": "config/area_registry/update", + "aliases": ["alias_1", "", " ", " alias_2 "], + "area_id": area.id, + "floor_id": None, + "humidity_entity_id": None, + "icon": None, + "labels": [], + "picture": None, + "temperature_entity_id": None, + } + ) + + msg = await client.receive_json() + + assert msg["result"] == { + "aliases": unordered(["alias_1", "alias_2"]), + "area_id": area.id, + "floor_id": None, + "icon": None, + "labels": [], + "name": "mock 2", + "picture": None, + "temperature_entity_id": None, + "humidity_entity_id": None, + "created_at": created_at.timestamp(), + "modified_at": modified_at.timestamp(), + } + assert len(area_registry.areas) == 1 + async def test_update_area_with_same_name( client: MockHAClientWebSocket, area_registry: ar.AreaRegistry diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py index 15a7ac70ac7b2..b6daf7027a6c2 100644 --- a/tests/components/config/test_entity_registry.py +++ b/tests/components/config/test_entity_registry.py @@ -887,6 +887,49 @@ async def test_update_entity( }, } + # Add illegal terms to aliases + await client.send_json_auto_id( + { + "type": "config/entity_registry/update", + "entity_id": "test_domain.world", + "aliases": ["alias_1", "alias_2", "", " alias_3 ", " "], + } + ) + + msg = await client.receive_json() + assert msg["success"] + + assert msg["result"] == { + "entity_entry": { + "aliases": unordered(["alias_1", "alias_2", "alias_3"]), + "area_id": "mock-area-id", + "capabilities": None, + "categories": {"scope1": "id", "scope3": "other_id"}, + "config_entry_id": None, + "config_subentry_id": None, + "created_at": created.timestamp(), + "device_class": "custom_device_class", + "device_id": None, + "disabled_by": None, + "entity_category": None, + "entity_id": "test_domain.world", + "has_entity_name": False, + "hidden_by": "user", # We exchange strings over the WS API, not enums + "icon": "icon:after update", + "id": ANY, + "labels": unordered(["label1", "label2"]), + "modified_at": modified.timestamp(), + "name": "after update", + "options": {"sensor": {"unit_of_measurement": "beard_second"}}, + "original_device_class": None, + "original_icon": None, + "original_name": None, + "platform": "test_platform", + "translation_key": None, + "unique_id": "1234", + }, + } + async def test_update_entity_require_restart( hass: HomeAssistant, client: MockHAClientWebSocket, freezer: FrozenDateTimeFactory diff --git a/tests/components/config/test_floor_registry.py b/tests/components/config/test_floor_registry.py index 3b0770aa976fe..6e563e72669e3 100644 --- a/tests/components/config/test_floor_registry.py +++ b/tests/components/config/test_floor_registry.py @@ -122,6 +122,30 @@ async def test_create_floor( "level": 2, } + # Floor with invalid aliases + await client.send_json_auto_id( + { + "name": "Third floor", + "type": "config/floor_registry/create", + "aliases": ["", " "], + "icon": "mdi:home-floor-2", + "level": 3, + } + ) + + msg = await client.receive_json() + + assert len(floor_registry.floors) == 3 + assert msg["result"] == { + "aliases": [], + "created_at": utcnow().timestamp(), + "icon": "mdi:home-floor-2", + "floor_id": "third_floor", + "modified_at": utcnow().timestamp(), + "name": "Third floor", + "level": 3, + } + async def test_create_floor_with_name_already_in_use( client: MockHAClientWebSocket, @@ -249,6 +273,60 @@ async def test_update_floor( "level": None, } + # Add invalid aliases + modified_at = datetime.fromisoformat("2024-07-16T13:55:00.900075+00:00") + freezer.move_to(modified_at) + await client.send_json_auto_id( + { + "floor_id": floor.floor_id, + "name": "First floor", + "aliases": ["top floor", "attic", "", " "], + "icon": None, + "level": None, + "type": "config/floor_registry/update", + } + ) + + msg = await client.receive_json() + + assert len(floor_registry.floors) == 1 + assert msg["result"] == { + "aliases": unordered(["top floor", "attic"]), + "created_at": created_at.timestamp(), + "icon": None, + "floor_id": floor.floor_id, + "modified_at": modified_at.timestamp(), + "name": "First floor", + "level": None, + } + + # Add alias with trailing and leading whitespaces + modified_at = datetime.fromisoformat("2024-07-16T13:55:00.900075+00:00") + freezer.move_to(modified_at) + await client.send_json_auto_id( + { + "floor_id": floor.floor_id, + "name": "First floor", + "aliases": ["top floor", "attic", "solaio "], + "icon": None, + "level": None, + "type": "config/floor_registry/update", + } + ) + + msg = await client.receive_json() + + assert len(floor_registry.floors) == 1 + assert msg["result"] == { + "aliases": unordered(["top floor", "attic", "solaio"]), + "created_at": created_at.timestamp(), + "icon": None, + "floor_id": floor.floor_id, + "modified_at": modified_at.timestamp(), + "name": "First floor", + "level": None, + } + async def test_update_with_name_already_in_use( client: MockHAClientWebSocket, diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 402c2df78d0f3..7457b99133efc 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -693,7 +693,7 @@ async def test_startstop_lawn_mower(hass: HomeAssistant) -> None: ), ], ) -async def test_startstop_cover_valve( +async def test_startstop_cover_valve_no_assumed_state( hass: HomeAssistant, domain: str, state_open: str, @@ -706,14 +706,14 @@ async def test_startstop_cover_valve( service_stop: str, service_toggle: str, ) -> None: - """Test startStop trait support.""" + """Test startStop trait support and no assumed state.""" assert helpers.get_google_type(domain, None) is not None assert trait.StartStopTrait.supported(domain, supported_features, None, None) state = State( f"{domain}.bla", state_closed, - {ATTR_SUPPORTED_FEATURES: supported_features}, + {ATTR_SUPPORTED_FEATURES: supported_features, ATTR_ASSUMED_STATE: False}, ) trt = trait.StartStopTrait( @@ -773,6 +773,168 @@ async def test_startstop_cover_valve( await trt.execute(trait.COMMAND_PAUSE_UNPAUSE, BASIC_DATA, {"start": True}, {}) +@pytest.mark.parametrize( + ( + "domain", + "state_open", + "state_closed", + "state_opening", + "state_closing", + "supported_features", + "service_close", + "service_open", + "service_stop", + "service_toggle", + "assumed_state", + ), + [ + ( + cover.DOMAIN, + cover.CoverState.OPEN, + cover.CoverState.CLOSED, + cover.CoverState.OPENING, + cover.CoverState.CLOSING, + CoverEntityFeature.STOP + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE, + cover.SERVICE_OPEN_COVER, + cover.SERVICE_CLOSE_COVER, + cover.SERVICE_STOP_COVER, + cover.SERVICE_TOGGLE, + True, + ), + ( + valve.DOMAIN, + valve.ValveState.OPEN, + valve.ValveState.CLOSED, + valve.ValveState.OPENING, + valve.ValveState.CLOSING, + ValveEntityFeature.STOP + | ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE, + valve.SERVICE_OPEN_VALVE, + valve.SERVICE_CLOSE_VALVE, + valve.SERVICE_STOP_VALVE, + cover.SERVICE_TOGGLE, + True, + ), + ( + cover.DOMAIN, + cover.CoverState.OPEN, + cover.CoverState.CLOSED, + cover.CoverState.OPENING, + cover.CoverState.CLOSING, + CoverEntityFeature.STOP + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION, + cover.SERVICE_OPEN_COVER, + cover.SERVICE_CLOSE_COVER, + cover.SERVICE_STOP_COVER, + cover.SERVICE_TOGGLE, + False, + ), + ( + valve.DOMAIN, + valve.ValveState.OPEN, + valve.ValveState.CLOSED, + valve.ValveState.OPENING, + valve.ValveState.CLOSING, + ValveEntityFeature.STOP + | ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.SET_POSITION, + valve.SERVICE_OPEN_VALVE, + valve.SERVICE_CLOSE_VALVE, + valve.SERVICE_STOP_VALVE, + cover.SERVICE_TOGGLE, + False, + ), + ], +) +async def test_startstop_cover_valve_with_assumed_state_or_reports_position( + hass: HomeAssistant, + domain: str, + state_open: str, + state_closed: str, + state_opening: str, + state_closing: str, + supported_features: str, + service_open: str, + service_close: str, + service_stop: str, + service_toggle: str, + assumed_state: bool, +) -> None: + """Test startStop trait support without an assumed state or reporting position.""" + assert helpers.get_google_type(domain, None) is not None + assert trait.StartStopTrait.supported(domain, supported_features, None, None) + + state = State( + f"{domain}.bla", + state_closed, + { + ATTR_SUPPORTED_FEATURES: supported_features, + ATTR_ASSUMED_STATE: assumed_state, + }, + ) + + trt = trait.StartStopTrait( + hass, + state, + BASIC_CONFIG, + ) + + assert trt.sync_attributes() == {} + + for state_value in (state_closing, state_opening): + state.state = state_value + assert trt.query_attributes()["isRunning"] is True + + stop_calls = async_mock_service(hass, domain, service_stop) + open_calls = async_mock_service(hass, domain, service_open) + close_calls = async_mock_service(hass, domain, service_close) + toggle_calls = async_mock_service(hass, domain, service_toggle) + await trt.execute(trait.COMMAND_START_STOP, BASIC_DATA, {"start": False}, {}) + assert len(stop_calls) == 1 + assert stop_calls[0].data == {ATTR_ENTITY_ID: f"{domain}.bla"} + + # Trait attr isRunning always returns True, + # so the cover or valve can always be stopped + for state_value in (state_closing, state_opening, state_closed, state_open): + state.state = state_value + assert trt.query_attributes()["isRunning"] is True + + state.state = state_open + + # Stop does not raise because we assume the state + # or the position is reported + await trt.execute(trait.COMMAND_START_STOP, BASIC_DATA, {"start": False}, {}) + assert len(stop_calls) == 2 + + # Start triggers toggle open + state.state = state_closed + await trt.execute(trait.COMMAND_START_STOP, BASIC_DATA, {"start": True}, {}) + assert len(open_calls) == 0 + assert len(close_calls) == 0 + assert len(toggle_calls) == 1 + assert toggle_calls[0].data == {ATTR_ENTITY_ID: f"{domain}.bla"} + # Second start triggers toggle close + state.state = state_open + await trt.execute(trait.COMMAND_START_STOP, BASIC_DATA, {"start": True}, {}) + assert len(open_calls) == 0 + assert len(close_calls) == 0 + assert len(toggle_calls) == 2 + assert toggle_calls[1].data == {ATTR_ENTITY_ID: f"{domain}.bla"} + + state.state = state_closed + with pytest.raises( + SmartHomeError, + match="Command action.devices.commands.PauseUnpause is not supported", + ): + await trt.execute(trait.COMMAND_PAUSE_UNPAUSE, BASIC_DATA, {"start": True}, {}) + + @pytest.mark.parametrize( ( "domain", diff --git a/tests/components/mealie/fixtures/get_mealplan_today.json b/tests/components/mealie/fixtures/get_mealplan_today.json index 1413f4a01133b..634c6fad449cf 100644 --- a/tests/components/mealie/fixtures/get_mealplan_today.json +++ b/tests/components/mealie/fixtures/get_mealplan_today.json @@ -110,7 +110,7 @@ }, { "date": "2024-01-21", - "entryType": "lunch", + "entryType": "dessert", "title": "", "text": "", "recipeId": "27455eb2-31d3-4682-84ff-02a114bf293a", @@ -178,7 +178,7 @@ }, { "date": "2024-01-21", - "entryType": "dinner", + "entryType": "snack", "title": "", "text": "", "recipeId": "48f39d27-4b8e-4c14-bf36-4e1e6497e75e", @@ -218,7 +218,7 @@ }, { "date": "2024-01-21", - "entryType": "dinner", + "entryType": "drink", "title": "", "text": "", "recipeId": "27455eb2-31d3-4682-84ff-02a114bf293a", diff --git a/tests/components/mealie/fixtures/get_mealplans.json b/tests/components/mealie/fixtures/get_mealplans.json index 89584a9c9171e..c7918ed8e80f5 100644 --- a/tests/components/mealie/fixtures/get_mealplans.json +++ b/tests/components/mealie/fixtures/get_mealplans.json @@ -256,7 +256,7 @@ }, { "date": "2024-01-23", - "entryType": "dinner", + "entryType": "dessert", "title": "", "text": "", "recipeId": "47595e4c-52bc-441d-b273-3edf4258806d", @@ -500,7 +500,7 @@ }, { "date": "2024-01-22", - "entryType": "dinner", + "entryType": "drink", "title": "", "text": "", "recipeId": "9d553779-607e-471b-acf3-84e6be27b159", @@ -574,7 +574,7 @@ }, { "date": "2024-01-22", - "entryType": "dinner", + "entryType": "snack", "title": "", "text": "", "recipeId": "55c88810-4cf1-4d86-ae50-63b15fd173fb", diff --git a/tests/components/mealie/snapshots/test_calendar.ambr b/tests/components/mealie/snapshots/test_calendar.ambr index 48f5aaa7d75d8..e97fd583db7c9 100644 --- a/tests/components/mealie/snapshots/test_calendar.ambr +++ b/tests/components/mealie/snapshots/test_calendar.ambr @@ -49,20 +49,6 @@ 'summary': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο (1)', 'uid': None, }), - dict({ - 'description': 'Delicious Greek turkey meatballs with lemon orzo, tender veggies, and a creamy feta yogurt sauce. These healthy baked Greek turkey meatballs are filled with tons of wonderful herbs and make the perfect protein-packed weeknight meal!', - 'end': dict({ - 'date': '2024-01-24', - }), - 'location': None, - 'recurrence_id': None, - 'rrule': None, - 'start': dict({ - 'date': '2024-01-23', - }), - 'summary': 'Greek Turkey Meatballs with Lemon Orzo & Creamy Feta Yogurt Sauce', - 'uid': None, - }), dict({ 'description': 'This is a modified Pampered Chef recipe. You can use a trifle bowl or large glass punch/salad bowl to show it off. It is really easy to make and I never have any leftovers. Cook time includes chill time.', 'end': dict({ @@ -105,20 +91,6 @@ 'summary': 'All-American Beef Stew Recipe', 'uid': None, }), - dict({ - 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', - 'end': dict({ - 'date': '2024-01-23', - }), - 'location': None, - 'recurrence_id': None, - 'rrule': None, - 'start': dict({ - 'date': '2024-01-22', - }), - 'summary': 'Einfacher Nudelauflauf mit Brokkoli', - 'uid': None, - }), dict({ 'description': 'Simple to prepare and ready in 25 minutes, this vegetarian miso noodle recipe can be eaten on its own or served as a side.', 'end': dict({ @@ -133,20 +105,6 @@ 'summary': 'Miso Udon Noodles with Spinach and Tofu', 'uid': None, }), - dict({ - 'description': 'Avis aux nostalgiques des années 1980, la mousse de saumon est de retour dans une présentation adaptée au goût du jour. On utilise une technique sans faille : un saumon frais cuit au micro-ondes et mélangé au robot avec du fromage à la crème et de la crème sure. On obtient ainsi une texture onctueuse à tartiner, qui n’a rien à envier aux préparations gélatineuses d’antan !', - 'end': dict({ - 'date': '2024-01-23', - }), - 'location': None, - 'recurrence_id': None, - 'rrule': None, - 'start': dict({ - 'date': '2024-01-22', - }), - 'summary': 'Mousse de saumon', - 'uid': None, - }), dict({ 'description': 'Dineren met de boys', 'end': dict({ diff --git a/tests/components/mealie/snapshots/test_diagnostics.ambr b/tests/components/mealie/snapshots/test_diagnostics.ambr index d8927a0963f69..42a0eccf13b81 100644 --- a/tests/components/mealie/snapshots/test_diagnostics.ambr +++ b/tests/components/mealie/snapshots/test_diagnostics.ambr @@ -36,6 +36,37 @@ 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), ]), + 'dessert': list([ + dict({ + 'description': None, + 'entry_type': 'dessert', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, + 'mealplan_date': dict({ + '__type': "", + 'isoformat': '2024-01-23', + }), + 'mealplan_id': 221, + 'recipe': dict({ + 'description': 'Delicious Greek turkey meatballs with lemon orzo, tender veggies, and a creamy feta yogurt sauce. These healthy baked Greek turkey meatballs are filled with tons of wonderful herbs and make the perfect protein-packed weeknight meal!', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, + 'image': 'Kn62', + 'name': 'Greek Turkey Meatballs with Lemon Orzo & Creamy Feta Yogurt Sauce', + 'original_url': 'https://www.ambitiouskitchen.com/greek-turkey-meatballs/', + 'perform_time': '20 Minutes', + 'prep_time': '40 Minutes', + 'rating': None, + 'recipe_id': '47595e4c-52bc-441d-b273-3edf4258806d', + 'recipe_yield': '4 servings', + 'slug': 'greek-turkey-meatballs-with-lemon-orzo-creamy-feta-yogurt-sauce', + 'total_time': '1 Hour', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + 'title': None, + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + ]), 'dinner': list([ dict({ 'description': None, @@ -95,35 +126,6 @@ 'title': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), - dict({ - 'description': None, - 'entry_type': 'dinner', - 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', - 'household_id': None, - 'mealplan_date': dict({ - '__type': "", - 'isoformat': '2024-01-23', - }), - 'mealplan_id': 221, - 'recipe': dict({ - 'description': 'Delicious Greek turkey meatballs with lemon orzo, tender veggies, and a creamy feta yogurt sauce. These healthy baked Greek turkey meatballs are filled with tons of wonderful herbs and make the perfect protein-packed weeknight meal!', - 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', - 'household_id': None, - 'image': 'Kn62', - 'name': 'Greek Turkey Meatballs with Lemon Orzo & Creamy Feta Yogurt Sauce', - 'original_url': 'https://www.ambitiouskitchen.com/greek-turkey-meatballs/', - 'perform_time': '20 Minutes', - 'prep_time': '40 Minutes', - 'rating': None, - 'recipe_id': '47595e4c-52bc-441d-b273-3edf4258806d', - 'recipe_yield': '4 servings', - 'slug': 'greek-turkey-meatballs-with-lemon-orzo-creamy-feta-yogurt-sauce', - 'total_time': '1 Hour', - 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', - }), - 'title': None, - 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', - }), dict({ 'description': None, 'entry_type': 'dinner', @@ -211,35 +213,6 @@ 'title': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), - dict({ - 'description': None, - 'entry_type': 'dinner', - 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', - 'household_id': None, - 'mealplan_date': dict({ - '__type': "", - 'isoformat': '2024-01-22', - }), - 'mealplan_id': 211, - 'recipe': dict({ - 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', - 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', - 'household_id': None, - 'image': 'nOPT', - 'name': 'Einfacher Nudelauflauf mit Brokkoli', - 'original_url': 'https://kochkarussell.com/einfacher-nudelauflauf-brokkoli/', - 'perform_time': '20 Minutes', - 'prep_time': '15 Minutes', - 'rating': None, - 'recipe_id': '9d553779-607e-471b-acf3-84e6be27b159', - 'recipe_yield': '4 servings', - 'slug': 'einfacher-nudelauflauf-mit-brokkoli', - 'total_time': '35 Minutes', - 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', - }), - 'title': None, - 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', - }), dict({ 'description': None, 'entry_type': 'dinner', @@ -270,48 +243,50 @@ 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), dict({ - 'description': None, + 'description': 'Dineren met de boys', 'entry_type': 'dinner', + 'group_id': '3931df86-0679-4579-8c63-4bedc9ca9a85', + 'household_id': None, + 'mealplan_date': dict({ + '__type': "", + 'isoformat': '2024-01-21', + }), + 'mealplan_id': 1, + 'recipe': None, + 'title': 'Aquavite', + 'user_id': '6caa6e4d-521f-4ef4-9ed7-388bdd63f47d', + }), + ]), + 'drink': list([ + dict({ + 'description': None, + 'entry_type': 'drink', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': dict({ '__type': "", 'isoformat': '2024-01-22', }), - 'mealplan_id': 195, + 'mealplan_id': 211, 'recipe': dict({ - 'description': 'Avis aux nostalgiques des années 1980, la mousse de saumon est de retour dans une présentation adaptée au goût du jour. On utilise une technique sans faille : un saumon frais cuit au micro-ondes et mélangé au robot avec du fromage à la crème et de la crème sure. On obtient ainsi une texture onctueuse à tartiner, qui n’a rien à envier aux préparations gélatineuses d’antan !', + 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, - 'image': 'rrNL', - 'name': 'Mousse de saumon', - 'original_url': 'https://www.ricardocuisine.com/recettes/8919-mousse-de-saumon', - 'perform_time': '2 Minutes', + 'image': 'nOPT', + 'name': 'Einfacher Nudelauflauf mit Brokkoli', + 'original_url': 'https://kochkarussell.com/einfacher-nudelauflauf-brokkoli/', + 'perform_time': '20 Minutes', 'prep_time': '15 Minutes', 'rating': None, - 'recipe_id': '55c88810-4cf1-4d86-ae50-63b15fd173fb', - 'recipe_yield': '12 servings', - 'slug': 'mousse-de-saumon', - 'total_time': '17 Minutes', + 'recipe_id': '9d553779-607e-471b-acf3-84e6be27b159', + 'recipe_yield': '4 servings', + 'slug': 'einfacher-nudelauflauf-mit-brokkoli', + 'total_time': '35 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), 'title': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), - dict({ - 'description': 'Dineren met de boys', - 'entry_type': 'dinner', - 'group_id': '3931df86-0679-4579-8c63-4bedc9ca9a85', - 'household_id': None, - 'mealplan_date': dict({ - '__type': "", - 'isoformat': '2024-01-21', - }), - 'mealplan_id': 1, - 'recipe': None, - 'title': 'Aquavite', - 'user_id': '6caa6e4d-521f-4ef4-9ed7-388bdd63f47d', - }), ]), 'lunch': list([ dict({ @@ -433,6 +408,37 @@ 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), ]), + 'snack': list([ + dict({ + 'description': None, + 'entry_type': 'snack', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, + 'mealplan_date': dict({ + '__type': "", + 'isoformat': '2024-01-22', + }), + 'mealplan_id': 195, + 'recipe': dict({ + 'description': 'Avis aux nostalgiques des années 1980, la mousse de saumon est de retour dans une présentation adaptée au goût du jour. On utilise une technique sans faille : un saumon frais cuit au micro-ondes et mélangé au robot avec du fromage à la crème et de la crème sure. On obtient ainsi une texture onctueuse à tartiner, qui n’a rien à envier aux préparations gélatineuses d’antan !', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, + 'image': 'rrNL', + 'name': 'Mousse de saumon', + 'original_url': 'https://www.ricardocuisine.com/recettes/8919-mousse-de-saumon', + 'perform_time': '2 Minutes', + 'prep_time': '15 Minutes', + 'rating': None, + 'recipe_id': '55c88810-4cf1-4d86-ae50-63b15fd173fb', + 'recipe_yield': '12 servings', + 'slug': 'mousse-de-saumon', + 'total_time': '17 Minutes', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + 'title': None, + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + ]), }), 'shoppinglist': dict({ '27edbaab-2ec6-441f-8490-0283ea77585f': dict({ diff --git a/tests/components/mealie/snapshots/test_services.ambr b/tests/components/mealie/snapshots/test_services.ambr index 41d03587a5b2b..30f70bc9273ba 100644 --- a/tests/components/mealie/snapshots/test_services.ambr +++ b/tests/components/mealie/snapshots/test_services.ambr @@ -1967,7 +1967,7 @@ }), dict({ 'description': None, - 'entry_type': , + 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': HAFakeDate(2024, 1, 23), @@ -2123,7 +2123,7 @@ }), dict({ 'description': None, - 'entry_type': , + 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': HAFakeDate(2024, 1, 22), @@ -2175,7 +2175,7 @@ }), dict({ 'description': None, - 'entry_type': , + 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': HAFakeDate(2024, 1, 22), diff --git a/tests/components/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr index 4d8e94d3f826a..94dfa420ee463 100644 --- a/tests/components/miele/snapshots/test_sensor.ambr +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -17,6 +17,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -73,6 +74,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -367,6 +369,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -423,6 +426,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -458,6 +462,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -514,6 +519,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -1431,6 +1437,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -1487,6 +1494,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -1522,6 +1530,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -1578,6 +1587,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -1613,6 +1623,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -1669,6 +1680,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -1872,6 +1884,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -1928,6 +1941,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -2453,6 +2467,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -2509,6 +2524,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -2600,6 +2616,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -2656,6 +2673,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -2691,6 +2709,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -2747,6 +2766,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -3706,6 +3726,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -3762,6 +3783,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -3853,6 +3875,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -3909,6 +3932,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -4823,6 +4847,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -4879,6 +4904,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -4970,6 +4996,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -5026,6 +5053,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -5061,6 +5089,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -5117,6 +5146,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -6076,6 +6106,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -6132,6 +6163,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -6223,6 +6255,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -6279,6 +6312,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -7193,6 +7227,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', @@ -7249,6 +7284,7 @@ 'program_ended', 'program_interrupted', 'programmed', + 'reserved', 'rinse_hold', 'service', 'supercooling', diff --git a/tests/components/music_assistant/test_init.py b/tests/components/music_assistant/test_init.py index 2126aa7cdff39..2c7ad8a0a741c 100644 --- a/tests/components/music_assistant/test_init.py +++ b/tests/components/music_assistant/test_init.py @@ -164,7 +164,6 @@ async def test_authentication_required_triggers_reauth( music_assistant_client: MagicMock, ) -> None: """Test that AuthenticationRequired exception triggers reauth flow.""" - # Create a config entry config_entry = MockConfigEntry( domain=DOMAIN, title="Music Assistant", @@ -173,19 +172,44 @@ async def test_authentication_required_triggers_reauth( ) config_entry.add_to_hass(hass) - # Mock the client to raise AuthenticationRequired during connect music_assistant_client.connect.side_effect = AuthenticationRequired( "Authentication required" ) - # Try to set up the integration await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - # Verify the entry is in SETUP_ERROR state (auth failed) assert config_entry.state is ConfigEntryState.SETUP_ERROR - # Verify a reauth repair issue was created issue_reg = ir.async_get(hass) issue_id = f"config_entry_reauth_{DOMAIN}_{config_entry.entry_id}" assert issue_reg.async_get_issue("homeassistant", issue_id) + + +async def test_authentication_required_addon_no_reauth( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test that AuthenticationRequired exception does not trigger reauth for addon.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="Music Assistant", + data={"url": "http://localhost:8095", "token": "old_token"}, + unique_id="test_server_id", + ) + config_entry.add_to_hass(hass) + + music_assistant_client.server_info.homeassistant_addon = True + + music_assistant_client.connect.side_effect = AuthenticationRequired( + "Authentication required" + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + issue_reg = ir.async_get(hass) + issue_id = f"config_entry_reauth_{DOMAIN}_{config_entry.entry_id}" + assert issue_reg.async_get_issue("homeassistant", issue_id) is None diff --git a/tests/components/nintendo_parental_controls/conftest.py b/tests/components/nintendo_parental_controls/conftest.py index eca9c1690c81a..6f4eded1e6690 100644 --- a/tests/components/nintendo_parental_controls/conftest.py +++ b/tests/components/nintendo_parental_controls/conftest.py @@ -6,7 +6,6 @@ from pynintendoparental import NintendoParental from pynintendoparental.device import Device -from pynintendoparental.exceptions import InvalidOAuthConfigurationException import pytest from homeassistant.components.nintendo_parental_controls.const import DOMAIN @@ -71,11 +70,9 @@ def mock_nintendo_authenticator() -> Generator[MagicMock]: mock_auth._at_expiry = datetime(2099, 12, 31, 23, 59, 59) mock_auth.account_id = ACCOUNT_ID mock_auth.login_url = LOGIN_URL - mock_auth.get_session_token = API_TOKEN - # Patch complete_login as an AsyncMock on both instance and class as this is a class method - mock_auth.complete_login = AsyncMock() - type(mock_auth).complete_login = mock_auth.complete_login - mock_auth_class.generate_login.return_value = mock_auth + mock_auth.session_token = API_TOKEN + mock_auth.async_complete_login = AsyncMock() + mock_auth_class.return_value = mock_auth yield mock_auth @@ -93,34 +90,6 @@ def mock_nintendo_api() -> Generator[AsyncMock]: yield mock_api_instance -@pytest.fixture -def mock_failed_nintendo_authenticator() -> Generator[MagicMock]: - """Mock a failed Nintendo Authenticator.""" - with ( - patch( - "homeassistant.components.nintendo_parental_controls.Authenticator", - autospec=True, - ) as mock_auth_class, - patch( - "homeassistant.components.nintendo_parental_controls.config_flow.Authenticator", - new=mock_auth_class, - ), - patch( - "homeassistant.components.nintendo_parental_controls.coordinator.NintendoParental.update", - return_value=None, - ), - ): - mock_auth = MagicMock() - mock_auth.complete_login = AsyncMock( - side_effect=InvalidOAuthConfigurationException( - status_code=401, - message="Authentication failed", - ) - ) - mock_auth_class.complete_login = mock_auth.complete_login - yield mock_auth - - @pytest.fixture def mock_nintendo_client( mock_nintendo_device: Device, mock_nintendo_authenticator: MagicMock diff --git a/tests/components/nintendo_parental_controls/test_config_flow.py b/tests/components/nintendo_parental_controls/test_config_flow.py index 1ba40d46fe1ba..cd9fc97028b2e 100644 --- a/tests/components/nintendo_parental_controls/test_config_flow.py +++ b/tests/components/nintendo_parental_controls/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from pynintendoparental.exceptions import HttpException, InvalidSessionTokenException +from pynintendoauth.exceptions import HttpException, InvalidSessionTokenException from homeassistant import config_entries from homeassistant.components.nintendo_parental_controls.const import ( @@ -82,7 +82,7 @@ async def test_invalid_auth( assert "link" in result["description_placeholders"] # Simulate invalid authentication by raising an exception - mock_nintendo_authenticator.complete_login.side_effect = ( + mock_nintendo_authenticator.async_complete_login.side_effect = ( InvalidSessionTokenException(status_code=401, message="Test") ) @@ -95,7 +95,7 @@ async def test_invalid_auth( assert result["errors"] == {"base": "invalid_auth"} # Now ensure that the flow can be recovered - mock_nintendo_authenticator.complete_login.side_effect = None + mock_nintendo_authenticator.async_complete_login.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_API_TOKEN: API_TOKEN} @@ -121,7 +121,7 @@ async def test_missing_devices( assert result["step_id"] == "user" assert "link" in result["description_placeholders"] - mock_nintendo_authenticator.complete_login.side_effect = None + mock_nintendo_authenticator.async_complete_login.side_effect = None mock_nintendo_api.async_get_account_devices.side_effect = HttpException( status_code=404, message="TEST" @@ -149,7 +149,7 @@ async def test_cannot_connect( assert result["step_id"] == "user" assert "link" in result["description_placeholders"] - mock_nintendo_authenticator.complete_login.side_effect = None + mock_nintendo_authenticator.async_complete_login.side_effect = None mock_nintendo_api.async_get_account_devices.side_effect = HttpException( status_code=500, message="TEST" @@ -209,7 +209,7 @@ async def test_reauthentication_fail( assert result["errors"] == {} # Simulate invalid authentication by raising an exception - mock_nintendo_authenticator.complete_login.side_effect = ( + mock_nintendo_authenticator.async_complete_login.side_effect = ( InvalidSessionTokenException(status_code=401, message="Test") ) @@ -222,7 +222,7 @@ async def test_reauthentication_fail( assert result["errors"] == {"base": "invalid_auth"} # Now ensure that the flow can be recovered - mock_nintendo_authenticator.complete_login.side_effect = None + mock_nintendo_authenticator.async_complete_login.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_API_TOKEN: API_TOKEN} diff --git a/tests/components/nintendo_parental_controls/test_coordinator.py b/tests/components/nintendo_parental_controls/test_coordinator.py index bb3530b06a6d7..7472f66125476 100644 --- a/tests/components/nintendo_parental_controls/test_coordinator.py +++ b/tests/components/nintendo_parental_controls/test_coordinator.py @@ -2,10 +2,8 @@ from unittest.mock import AsyncMock -from pynintendoparental.exceptions import ( - InvalidOAuthConfigurationException, - NoDevicesFoundException, -) +from pynintendoauth.exceptions import InvalidOAuthConfigurationException +from pynintendoparental.exceptions import NoDevicesFoundException from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant diff --git a/tests/components/nintendo_parental_controls/test_init.py b/tests/components/nintendo_parental_controls/test_init.py index 801cdfe3c0233..b149bae0b8531 100644 --- a/tests/components/nintendo_parental_controls/test_init.py +++ b/tests/components/nintendo_parental_controls/test_init.py @@ -14,10 +14,11 @@ async def test_invalid_authentication( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_failed_nintendo_authenticator: AsyncMock, + mock_nintendo_authenticator: AsyncMock, entity_registry: er.EntityRegistry, ) -> None: """Test handling of invalid authentication.""" + mock_nintendo_authenticator.async_complete_login.side_effect = ValueError await setup_integration(hass, mock_config_entry) # Ensure no entities are created diff --git a/tests/components/plugwise/snapshots/test_select.ambr b/tests/components/plugwise/snapshots/test_select.ambr index c2680f7bcea4a..90ace520e2d93 100644 --- a/tests/components/plugwise/snapshots/test_select.ambr +++ b/tests/components/plugwise/snapshots/test_select.ambr @@ -141,7 +141,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'select', - 'entity_category': , + 'entity_category': None, 'entity_id': 'select.bathroom_thermostat_schedule', 'has_entity_name': True, 'hidden_by': None, @@ -263,7 +263,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'select', - 'entity_category': , + 'entity_category': None, 'entity_id': 'select.living_room_thermostat_schedule', 'has_entity_name': True, 'hidden_by': None, @@ -386,7 +386,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'select', - 'entity_category': , + 'entity_category': None, 'entity_id': 'select.badkamer_thermostat_schedule', 'has_entity_name': True, 'hidden_by': None, @@ -451,7 +451,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'select', - 'entity_category': , + 'entity_category': None, 'entity_id': 'select.bios_thermostat_schedule', 'has_entity_name': True, 'hidden_by': None, @@ -516,7 +516,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'select', - 'entity_category': , + 'entity_category': None, 'entity_id': 'select.jessie_thermostat_schedule', 'has_entity_name': True, 'hidden_by': None, @@ -581,7 +581,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'select', - 'entity_category': , + 'entity_category': None, 'entity_id': 'select.woonkamer_thermostat_schedule', 'has_entity_name': True, 'hidden_by': None, diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 0e712542f8d96..593d51997fc9a 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -2,6 +2,7 @@ import asyncio from collections.abc import Callable +from datetime import UTC, datetime, timedelta from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch @@ -22,6 +23,7 @@ BATTERY_ALL_WAKE_UPDATE_INTERVAL, BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL, CONF_BC_PORT, + CONF_FIRMWARE_CHECK_TIME, DOMAIN, ) from homeassistant.config_entries import ConfigEntryState @@ -47,6 +49,7 @@ from homeassistant.setup import async_setup_component from .conftest import ( + CONF_BC_ONLY, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DEFAULT_PROTOCOL, @@ -58,6 +61,7 @@ TEST_MAC, TEST_MAC_CAM, TEST_NVR_NAME, + TEST_PASSWORD, TEST_PORT, TEST_PRIVACY, TEST_UID, @@ -146,10 +150,14 @@ async def test_firmware_error_twice( assert config_entry.state is ConfigEntryState.LOADED + freezer.tick(FIRMWARE_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + entity_id = f"{Platform.UPDATE}.{TEST_NVR_NAME}_firmware" assert hass.states.get(entity_id).state == STATE_OFF - freezer.tick(FIRMWARE_UPDATE_INTERVAL) + freezer.tick(2 * FIRMWARE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -1130,6 +1138,53 @@ def register_callback( assert hass.states.get(entity_id).state == STATE_OFF +@pytest.mark.parametrize(("seconds", "call_count"), [(10, 1), (3600, 0)]) +async def test_firmware_update_delay( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + reolink_host: MagicMock, + seconds: int, + call_count: int, +) -> None: + """Test delay of firmware update check.""" + now = datetime.now(UTC) + check_delay = ( + now + + timedelta(seconds=seconds) + - now.replace(hour=0, minute=0, second=0, microsecond=0) + ).total_seconds() + + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=format_mac(TEST_MAC), + data={ + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_PORT: TEST_PORT, + CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, + CONF_BC_PORT: TEST_BC_PORT, + CONF_BC_ONLY: False, + CONF_FIRMWARE_CHECK_TIME: check_delay, + }, + options={ + CONF_PROTOCOL: DEFAULT_PROTOCOL, + }, + title=TEST_NVR_NAME, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + freezer.tick(60) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert reolink_host.check_new_firmware.call_count == call_count + + async def test_baichaun_only( hass: HomeAssistant, reolink_host: MagicMock, diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index b2330ae793ad6..8ed1ebaad169f 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -1,9 +1,11 @@ """Test for Roborock init.""" +import datetime import pathlib from typing import Any from unittest.mock import AsyncMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from roborock import ( RoborockInvalidCredentials, @@ -11,10 +13,15 @@ RoborockNoUserAgreement, ) from roborock.exceptions import RoborockException +from roborock.mqtt.session import MqttSessionUnauthorized +from homeassistant.components.homeassistant import ( + DOMAIN as HA_DOMAIN, + SERVICE_UPDATE_ENTITY, +) from homeassistant.components.roborock.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import Platform +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.device_registry import DeviceRegistry @@ -23,7 +30,7 @@ from .conftest import FakeDevice from .mock_data import ROBOROCK_RRUID, USER_EMAIL -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import ClientSessionGenerator @@ -64,13 +71,18 @@ async def test_home_assistant_stop( assert device_manager.close.called +@pytest.mark.parametrize( + "side_effect", [RoborockInvalidCredentials(), MqttSessionUnauthorized()] +) async def test_reauth_started( - hass: HomeAssistant, mock_roborock_entry: MockConfigEntry + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + side_effect: Exception, ) -> None: """Test reauth flow started.""" with patch( "homeassistant.components.roborock.create_device_manager", - side_effect=RoborockInvalidCredentials(), + side_effect=side_effect, ): await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() @@ -295,6 +307,72 @@ async def test_migrate_config_entry_unique_id( assert config_entry.unique_id == ROBOROCK_RRUID +@pytest.mark.parametrize("platforms", [[Platform.SENSOR]]) +async def test_update_unavailability_threshold( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_entry: MockConfigEntry, + fake_vacuum: FakeDevice, +) -> None: + """Test that a small number of update failures are suppressed before marking a device unavailable.""" + await async_setup_component(hass, HA_DOMAIN, {}) + assert setup_entry.state is ConfigEntryState.LOADED + + # We pick an arbitrary sensor to test for availability + sensor_entity_id = "sensor.roborock_s7_maxv_battery" + expected_state = "100" + state = hass.states.get(sensor_entity_id) + assert state is not None + assert state.state == expected_state + + # Simulate a few update failures below the threshold + assert fake_vacuum.v1_properties is not None + fake_vacuum.v1_properties.status.refresh.side_effect = RoborockException( + "Simulated update failure" + ) + + # Move forward in time less than the threshold + freezer.tick(datetime.timedelta(seconds=90)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Force a coordinator refresh. + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: sensor_entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + # Verify that the entity is still available + state = hass.states.get(sensor_entity_id) + assert state is not None + assert state.state == expected_state + + # Move forward in time to exceed the threshold + freezer.tick(datetime.timedelta(minutes=3)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Verify that the entity is now unavailable + state = hass.states.get(sensor_entity_id) + assert state is not None + assert state.state == "unavailable" + + # Now restore normal update behavior and refresh. + fake_vacuum.v1_properties.status.refresh.side_effect = None + + freezer.tick(datetime.timedelta(seconds=45)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Verify that the entity recovers and is available again + state = hass.states.get(sensor_entity_id) + assert state is not None + assert state.state == expected_state + + async def test_cloud_api_repair( hass: HomeAssistant, mock_roborock_entry: MockConfigEntry, diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py index 88c639b4b83e7..8c05e13b1b0a9 100644 --- a/tests/components/roborock/test_select.py +++ b/tests/components/roborock/test_select.py @@ -5,6 +5,7 @@ import pytest from roborock import RoborockCommand +from roborock.data.v1 import RoborockDockDustCollectionModeCode from roborock.exceptions import RoborockException from homeassistant.components.roborock import DOMAIN @@ -154,3 +155,30 @@ async def test_selected_map_without_name( select_entity = hass.states.get("select.roborock_s7_maxv_selected_map") assert select_entity assert select_entity.state == "Map 0" + + +@pytest.mark.parametrize( + ("dust_collection_mode", "expected_state"), + [ + (None, "unknown"), + (RoborockDockDustCollectionModeCode.smart, "smart"), + (RoborockDockDustCollectionModeCode.light, "light"), + ], +) +async def test_dust_collection_mode_none( + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + fake_vacuum: FakeDevice, + dust_collection_mode: RoborockDockDustCollectionModeCode | None, + expected_state: str, +) -> None: + """Test that the dust collection mode entity correctly handles mode values.""" + assert fake_vacuum.v1_properties + assert fake_vacuum.v1_properties.dust_collection_mode + fake_vacuum.v1_properties.dust_collection_mode.mode = dust_collection_mode + + await async_setup_component(hass, DOMAIN, {}) + + select_entity = hass.states.get("select.roborock_s7_maxv_dock_empty_mode") + assert select_entity + assert select_entity.state == expected_state diff --git a/tests/components/sonos/test_speaker.py b/tests/components/sonos/test_speaker.py index cdb7be1558971..c96805779f8b5 100644 --- a/tests/components/sonos/test_speaker.py +++ b/tests/components/sonos/test_speaker.py @@ -3,6 +3,7 @@ from unittest.mock import patch import pytest +from soco.exceptions import SoCoException from homeassistant.components.media_player import ( DOMAIN as MP_DOMAIN, @@ -189,3 +190,35 @@ async def test_zgs_avtransport_group_speakers( await _media_play(hass, "media_player.living_room") assert soco_lr.play.call_count == 1 assert soco_br.play.call_count == 0 + + +async def test_async_offline_without_subscription_lock( + hass: HomeAssistant, + config_entry: MockConfigEntry, + soco: MockSoCo, +) -> None: + """Test unloading entry works when subscription lock was never created. + + This can happen when a speaker is discovered but setup() fails early + before async_setup() is scheduled. The integration should handle this + gracefully during unload. + """ + # Make play_mode raise an exception to cause setup() to fail early. + # The speaker is added to discovered before setup() is called in _add_speaker, + # so this creates a speaker in discovered without _subscription_lock being created. + # Using PropertyMock to only affect this specific test's soco instance. + with patch.object( + type(soco), + "play_mode", + new_callable=lambda: property( + fget=lambda self: (_ for _ in ()).throw(SoCoException("Connection failed")) + ), + ): + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + # Unload should succeed without AssertionError even though + # _subscription_lock was never created + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) diff --git a/tests/components/system_bridge/__init__.py b/tests/components/system_bridge/__init__.py index 89bd1b652ba80..129fd314f840b 100644 --- a/tests/components/system_bridge/__init__.py +++ b/tests/components/system_bridge/__init__.py @@ -4,16 +4,16 @@ from ipaddress import ip_address from typing import Any -from systembridgemodels.fixtures.modules.battery import FIXTURE_BATTERY -from systembridgemodels.fixtures.modules.cpu import FIXTURE_CPU -from systembridgemodels.fixtures.modules.disks import FIXTURE_DISKS -from systembridgemodels.fixtures.modules.displays import FIXTURE_DISPLAYS -from systembridgemodels.fixtures.modules.gpus import FIXTURE_GPUS -from systembridgemodels.fixtures.modules.media import FIXTURE_MEDIA -from systembridgemodels.fixtures.modules.memory import FIXTURE_MEMORY -from systembridgemodels.fixtures.modules.processes import FIXTURE_PROCESSES -from systembridgemodels.fixtures.modules.system import FIXTURE_SYSTEM -from systembridgemodels.modules import Module, ModulesData +from systembridgeconnector.models.fixtures.modules.battery import FIXTURE_BATTERY +from systembridgeconnector.models.fixtures.modules.cpu import FIXTURE_CPU +from systembridgeconnector.models.fixtures.modules.disks import FIXTURE_DISKS +from systembridgeconnector.models.fixtures.modules.displays import FIXTURE_DISPLAYS +from systembridgeconnector.models.fixtures.modules.gpus import FIXTURE_GPUS +from systembridgeconnector.models.fixtures.modules.media import FIXTURE_MEDIA +from systembridgeconnector.models.fixtures.modules.memory import FIXTURE_MEMORY +from systembridgeconnector.models.fixtures.modules.processes import FIXTURE_PROCESSES +from systembridgeconnector.models.fixtures.modules.system import FIXTURE_SYSTEM +from systembridgeconnector.models.modules import Module, ModulesData from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN from homeassistant.core import HomeAssistant diff --git a/tests/components/system_bridge/conftest.py b/tests/components/system_bridge/conftest.py index 2f1f87485e7d8..67bafe67bad51 100644 --- a/tests/components/system_bridge/conftest.py +++ b/tests/components/system_bridge/conftest.py @@ -8,21 +8,25 @@ import pytest from systembridgeconnector.const import EventKey, EventType -from systembridgemodels.fixtures.modules.battery import FIXTURE_BATTERY -from systembridgemodels.fixtures.modules.cpu import FIXTURE_CPU -from systembridgemodels.fixtures.modules.disks import FIXTURE_DISKS -from systembridgemodels.fixtures.modules.displays import FIXTURE_DISPLAYS -from systembridgemodels.fixtures.modules.gpus import FIXTURE_GPUS -from systembridgemodels.fixtures.modules.media import FIXTURE_MEDIA -from systembridgemodels.fixtures.modules.memory import FIXTURE_MEMORY -from systembridgemodels.fixtures.modules.networks import FIXTURE_NETWORKS -from systembridgemodels.fixtures.modules.processes import FIXTURE_PROCESSES -from systembridgemodels.fixtures.modules.sensors import FIXTURE_SENSORS -from systembridgemodels.fixtures.modules.system import FIXTURE_SYSTEM -from systembridgemodels.media_directories import MediaDirectory -from systembridgemodels.media_files import MediaFile, MediaFiles -from systembridgemodels.modules import Module, ModulesData, RegisterDataListener -from systembridgemodels.response import Response +from systembridgeconnector.models.fixtures.modules.battery import FIXTURE_BATTERY +from systembridgeconnector.models.fixtures.modules.cpu import FIXTURE_CPU +from systembridgeconnector.models.fixtures.modules.disks import FIXTURE_DISKS +from systembridgeconnector.models.fixtures.modules.displays import FIXTURE_DISPLAYS +from systembridgeconnector.models.fixtures.modules.gpus import FIXTURE_GPUS +from systembridgeconnector.models.fixtures.modules.media import FIXTURE_MEDIA +from systembridgeconnector.models.fixtures.modules.memory import FIXTURE_MEMORY +from systembridgeconnector.models.fixtures.modules.networks import FIXTURE_NETWORKS +from systembridgeconnector.models.fixtures.modules.processes import FIXTURE_PROCESSES +from systembridgeconnector.models.fixtures.modules.sensors import FIXTURE_SENSORS +from systembridgeconnector.models.fixtures.modules.system import FIXTURE_SYSTEM +from systembridgeconnector.models.media_directories import MediaDirectory +from systembridgeconnector.models.media_files import MediaFile, MediaFiles +from systembridgeconnector.models.modules import ( + Module, + ModulesData, + RegisterDataListener, +) +from systembridgeconnector.models.response import Response from homeassistant.components.system_bridge.config_flow import SystemBridgeConfigFlow from homeassistant.components.system_bridge.const import DOMAIN @@ -130,6 +134,7 @@ def mock_websocket_client( websocket_client.get_directories.return_value = [ MediaDirectory( key="documents", + name="Documents", path="/home/user/documents", ) ] @@ -143,6 +148,8 @@ def mock_websocket_client( last_accessed=1630000000, created=1630000000, modified=1630000000, + mod_time=1630000000, + permissions="rwxr-xr-x", is_directory=True, is_file=False, is_link=False, @@ -155,6 +162,8 @@ def mock_websocket_client( last_accessed=1630000000, created=1630000000, modified=1630000000, + mod_time=1630000000, + permissions="rw-r--r--", is_directory=False, is_file=True, is_link=False, @@ -168,6 +177,8 @@ def mock_websocket_client( last_accessed=1630000000, created=1630000000, modified=1630000000, + mod_time=1630000000, + permissions="rw-r--r--", is_directory=False, is_file=True, is_link=False, diff --git a/tests/components/volvo/snapshots/test_binary_sensor.ambr b/tests/components/volvo/snapshots/test_binary_sensor.ambr index 2ebbe4890888d..40922692108a4 100644 --- a/tests/components/volvo/snapshots/test_binary_sensor.ambr +++ b/tests/components/volvo/snapshots/test_binary_sensor.ambr @@ -538,6 +538,55 @@ 'state': 'off', }) # --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_engine_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.volvo_ex30_engine_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Engine status', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'engine_status', + 'unique_id': 'yv1abcdefg1234567_engine_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_engine_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Volvo EX30 Engine status', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_ex30_engine_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensor[ex30_2024][binary_sensor.volvo_ex30_fog_light_front-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -4801,6 +4850,55 @@ 'state': 'off', }) # --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_engine_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.volvo_xc40_engine_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Engine status', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'engine_status', + 'unique_id': 'yv1abcdefg1234567_engine_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_engine_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Volvo XC40 Engine status', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_xc40_engine_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensor[xc40_electric_2024][binary_sensor.volvo_xc40_fog_light_front-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/volvo/snapshots/test_diagnostics.ambr b/tests/components/volvo/snapshots/test_diagnostics.ambr index 8b98a69782969..470a4a9d76468 100644 --- a/tests/components/volvo/snapshots/test_diagnostics.ambr +++ b/tests/components/volvo/snapshots/test_diagnostics.ambr @@ -167,6 +167,13 @@ 'unit': 'mi', 'value': 150, }), + 'engineStatus': dict({ + 'extra_data': dict({ + }), + 'timestamp': '2024-12-30T15:00:00+00:00', + 'unit': None, + 'value': 'STOPPED', + }), 'estimatedChargingTimeToTargetBatteryChargeLevel': dict({ 'extra_data': dict({ 'updated_at': '2025-07-02T08:51:23Z',