diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1a4e0cbfe1f82..9386de6f4d081 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,6 +11,10 @@ updates: - github_actions cooldown: default-days: 7 + groups: + codeql-action: + patterns: + - "github/codeql-action/*" ignore: # Managed by gh aw compile. Version-locked to the gh-aw compiler; do not bump. - dependency-name: "github/gh-aw-actions/**" diff --git a/CODEOWNERS b/CODEOWNERS index 099f990624cf3..1496f15a4c549 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1750,6 +1750,8 @@ CLAUDE.md @home-assistant/core /tests/components/smhi/ @gjohansson-ST /homeassistant/components/smlight/ @tl-sl /tests/components/smlight/ @tl-sl +/homeassistant/components/smtp/ @tr4nt0r +/tests/components/smtp/ @tr4nt0r /homeassistant/components/snapcast/ @luar123 /tests/components/snapcast/ @luar123 /homeassistant/components/snmp/ @nmaggioni diff --git a/homeassistant/components/blink/coordinator.py b/homeassistant/components/blink/coordinator.py index 0464e78f271be..3dc3572efe6f1 100644 --- a/homeassistant/components/blink/coordinator.py +++ b/homeassistant/components/blink/coordinator.py @@ -62,6 +62,11 @@ async def _async_setup(self): async def _async_update_data(self) -> dict[str, Any]: """Async update wrapper.""" try: - return await self.api.refresh(force=True) + # Do not force the refresh. BlinkPy turns a forced refresh into + # force_cache=True, which makes every sync module re-download media + # it already has, so cached clips are fetched again on every poll. + # The scan interval is far longer than BlinkPy's refresh rate, so a + # normal refresh still happens on schedule. + return await self.api.refresh() except UnauthorizedError as ex: raise ConfigEntryAuthFailed("Blink API authorization failed") from ex diff --git a/homeassistant/components/brands/__init__.py b/homeassistant/components/brands/__init__.py index a1a6ab22e2bdc..544c2440bf4eb 100644 --- a/homeassistant/components/brands/__init__.py +++ b/homeassistant/components/brands/__init__.py @@ -155,32 +155,38 @@ async def _serve_from_custom_integration( return None - async def _serve_from_cache_or_cdn( + async def _get_image_data( self, cdn_path: str, cache_subpath: str, - *, - fallback_placeholder: bool = True, - ) -> web.Response: - """Serve from disk cache, fetching from CDN if needed.""" + ) -> bytes | None: + """Read image data from the disk cache, fetching from CDN if needed.""" cache_path = self._cache_dir / cache_subpath - now = time.time() - # Try disk cache result = await self._hass.async_add_executor_job( _read_cached_file_with_marker, cache_path ) - if result is not None: - data, mtime = result - # Schedule background refresh if stale - if now - mtime > CACHE_TTL: - self._hass.async_create_background_task( - self._fetch_and_cache(cdn_path, cache_path), - f"brands_refresh_{cache_subpath}", - ) - else: - # Cache miss - fetch from CDN - data = await self._fetch_and_cache(cdn_path, cache_path) + if result is None: + return await self._fetch_and_cache(cdn_path, cache_path) + + data, mtime = result + # Schedule background refresh if stale + if time.time() - mtime > CACHE_TTL: + self._hass.async_create_background_task( + self._fetch_and_cache(cdn_path, cache_path), + f"brands_refresh_{cache_subpath}", + ) + return data + + async def _serve_from_cache_or_cdn( + self, + cdn_path: str, + cache_subpath: str, + *, + fallback_placeholder: bool = True, + ) -> web.Response: + """Serve from disk cache, fetching from CDN if needed.""" + data = await self._get_image_data(cdn_path, cache_subpath) if data is None: if fallback_placeholder: @@ -260,10 +266,21 @@ async def get( ) is not None: return response - # 2. Try cache / CDN (always use direct path for proper 404 caching) + # 2. Try the integration image. Direct paths are used instead of the + # "_/" namespace so real 404s can be cached as markers. + if ( + data := await self._get_image_data( + cdn_path=f"{domain}/{image}", + cache_subpath=f"integrations/{domain}/{image}", + ) + ) is not None: + return self._build_response(data) + + # 3. Fall back to the brand image, which is cached separately so the + # integration 404 marker is preserved. return await self._serve_from_cache_or_cdn( cdn_path=f"brands/{domain}/{image}", - cache_subpath=f"integrations/{domain}/{image}", + cache_subpath=f"brands/{domain}/{image}", fallback_placeholder=use_placeholder, ) diff --git a/homeassistant/components/collection_image/manifest.json b/homeassistant/components/collection_image/manifest.json index ee5118b51c804..530ff5e4eb3f1 100644 --- a/homeassistant/components/collection_image/manifest.json +++ b/homeassistant/components/collection_image/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/collection_image", "integration_type": "service", "iot_class": "calculated", - "quality_scale": "bronze" + "quality_scale": "silver" } diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 8927ce6310b3e..7cf36eed470e8 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -20,7 +20,7 @@ from homeassistant.util.json import JsonObjectType from . import trace -from .const import ChatLogEventType +from .const import DOMAIN, ChatLogEventType from .models import ConversationInput, ConversationResult DATA_CHAT_LOGS: HassKey[dict[str, ChatLog]] = HassKey("conversation_chat_logs") @@ -298,7 +298,20 @@ class ToolResultContent: @property def tool_result(self) -> JsonObjectType: - """Return the data of the result.""" + """Return the data of the result. + + Deprecated compatibility shim: the result is available as `result`. + It can be removed in HA Core 2027.11. + """ + frame.report_usage( + "accesses `ToolResultContent.tool_result`, which is deprecated; " + "use `ToolResultContent.result` instead", + breaks_in_ha_version="2027.11.0", + core_behavior=frame.ReportBehavior.ERROR, + core_integration_behavior=frame.ReportBehavior.ERROR, + custom_integration_behavior=frame.ReportBehavior.LOG, + exclude_integrations={DOMAIN}, + ) return self.result.data def as_dict(self) -> dict[str, Any]: @@ -309,6 +322,7 @@ def as_dict(self) -> dict[str, Any]: "tool_call_id": self.tool_call_id, "tool_name": self.tool_name, "result": asdict(self.result), + # Deprecated, can be removed in HA Core 2027.11. "tool_result": self.result.data, "created": self.created, } @@ -594,6 +608,15 @@ async def async_add_delta_content_stream( self.delta_listener(self, filtered_delta) elif delta["role"] == "tool_result": if (result := delta.get("result")) is None: + frame.report_usage( + "sets `tool_result` on a tool result delta, which is " + "deprecated; set `result` to a ToolResult instead", + breaks_in_ha_version="2027.11.0", + core_behavior=frame.ReportBehavior.ERROR, + core_integration_behavior=frame.ReportBehavior.ERROR, + custom_integration_behavior=frame.ReportBehavior.LOG, + exclude_integrations={DOMAIN}, + ) result = llm.ToolResult(data=delta["tool_result"]) content = ToolResultContent( agent_id=agent_id, diff --git a/homeassistant/components/cover/intent.py b/homeassistant/components/cover/intent.py index 2915b8425d3c3..4cfe7d7c514ec 100644 --- a/homeassistant/components/cover/intent.py +++ b/homeassistant/components/cover/intent.py @@ -18,6 +18,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None: SERVICE_OPEN_COVER, description="Opens a cover", platforms={DOMAIN}, + required_domains={DOMAIN}, device_classes={CoverDeviceClass}, ), ) @@ -29,6 +30,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None: SERVICE_CLOSE_COVER, description="Closes a cover", platforms={DOMAIN}, + required_domains={DOMAIN}, device_classes={CoverDeviceClass}, ), ) diff --git a/homeassistant/components/de_dietrich/manifest.json b/homeassistant/components/de_dietrich/manifest.json index e980c3c99c7ca..cacf02a8101f6 100644 --- a/homeassistant/components/de_dietrich/manifest.json +++ b/homeassistant/components/de_dietrich/manifest.json @@ -8,5 +8,5 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["diematic-modbus==0.7.5"] + "requirements": ["diematic-modbus==0.8.0"] } diff --git a/homeassistant/components/devolo_home_network/device_tracker.py b/homeassistant/components/devolo_home_network/device_tracker.py index 185e01a09432d..4595ad83ee24d 100644 --- a/homeassistant/components/devolo_home_network/device_tracker.py +++ b/homeassistant/components/devolo_home_network/device_tracker.py @@ -32,47 +32,30 @@ async def async_setup_entry( str, DevoloDataUpdateCoordinator[dict[str, ConnectedStationInfo]] ] = entry.runtime_data.coordinators registry = er.async_get(hass) - tracked = set() @callback def new_device_callback() -> None: - """Add new devices if needed.""" - new_entities = [] - for mac_address in coordinators[CONNECTED_WIFI_CLIENTS].data: - if mac_address in tracked: - continue - - new_entities.append( - DevoloScannerEntity( - coordinators[CONNECTED_WIFI_CLIENTS], device, mac_address - ) + """Add clients that don't have an entity yet.""" + async_add_entities( + DevoloScannerEntity(coordinators[CONNECTED_WIFI_CLIENTS], device, mac) + for mac in coordinators[CONNECTED_WIFI_CLIENTS].data + if not registry.async_get_entity_id( + DEVICE_TRACKER_DOMAIN, DOMAIN, f"{device.serial_number}_{mac}" ) - tracked.add(mac_address) - async_add_entities(new_entities) + ) @callback def restore_entities() -> None: """Restore clients that are not a part of active clients list.""" - missing = [] - for entity in er.async_entries_for_config_entry(registry, entry.entry_id): - if ( - entity.platform == DOMAIN - and entity.domain == DEVICE_TRACKER_DOMAIN - and ( - mac_address := entity.unique_id.replace( - f"{device.serial_number}_", "" - ) - ) - not in tracked - ): - missing.append( - DevoloScannerEntity( - coordinators[CONNECTED_WIFI_CLIENTS], device, mac_address - ) - ) - tracked.add(mac_address) - - async_add_entities(missing) + async_add_entities( + DevoloScannerEntity( + coordinators[CONNECTED_WIFI_CLIENTS], + device, + entity.unique_id.removeprefix(f"{device.serial_number}_"), + ) + for entity in er.async_entries_for_config_entry(registry, entry.entry_id) + if entity.platform == DOMAIN and entity.domain == DEVICE_TRACKER_DOMAIN + ) restore_entities() new_device_callback() diff --git a/homeassistant/components/dlink/const.py b/homeassistant/components/dlink/const.py index b39cd8be47658..0828c2e4ee130 100644 --- a/homeassistant/components/dlink/const.py +++ b/homeassistant/components/dlink/const.py @@ -2,6 +2,7 @@ ATTRIBUTION = "Data provided by D-Link" ATTR_TOTAL_CONSUMPTION = "total_consumption" +ATTR_CURRENT_CONSUMPTION = "current_consumption" CONF_USE_LEGACY_PROTOCOL = "use_legacy_protocol" diff --git a/homeassistant/components/dlink/switch.py b/homeassistant/components/dlink/switch.py index 19b2559841821..371cccda2b462 100644 --- a/homeassistant/components/dlink/switch.py +++ b/homeassistant/components/dlink/switch.py @@ -9,7 +9,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DLinkConfigEntry -from .const import ATTR_TOTAL_CONSUMPTION +from .const import ATTR_CURRENT_CONSUMPTION, ATTR_TOTAL_CONSUMPTION from .entity import DLinkEntity SCAN_INTERVAL = timedelta(minutes=2) @@ -49,9 +49,15 @@ def extra_state_attributes(self) -> dict[str, Any]: except ValueError: total_consumption = None + try: + current_consumption = float(self.data.current_consumption) + except ValueError: + current_consumption = None + return { ATTR_TOTAL_CONSUMPTION: total_consumption, ATTR_TEMPERATURE: temperature, + ATTR_CURRENT_CONSUMPTION: current_consumption, } @property diff --git a/homeassistant/components/emulated_hue/config.py b/homeassistant/components/emulated_hue/config.py index c5cd4a6e76c38..888e527fc81a9 100644 --- a/homeassistant/components/emulated_hue/config.py +++ b/homeassistant/components/emulated_hue/config.py @@ -253,7 +253,9 @@ def _is_state_exposed(self, state: State) -> bool: return not self._entities_with_hidden_attr_in_config[state.entity_id] if not self.expose_by_default: - return False + # Listing an entity is the documented way to expose just a few + # devices when expose_by_default is off + return state.entity_id in self.entities # Expose an entity if the entity's domain is exposed by default and # the configuration doesn't explicitly exclude it from being # exposed, or if the entity is explicitly exposed diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index 6dd499bf45621..3e10983a21085 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["env_canada"], - "requirements": ["env-canada==0.20.0"] + "requirements": ["env-canada==0.20.3"] } diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index ad3831f369142..1d096cd13fbb0 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -336,10 +336,11 @@ def _modbus_mppt_descriptions( MODBUS_INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ - # SunSpec model 160 supports up to 4 MPPT modules (GEN24 hybrid, Tauro) + # Verto Plus exposes 5 modules (3 PV trackers plus storage charge/discharge), + # one more leaves headroom for a hybrid with 4 PV trackers *( description - for mppt_no in range(1, 5) + for mppt_no in range(1, 7) for description in _modbus_mppt_descriptions(mppt_no) ), FroniusSensorEntityDescription( diff --git a/homeassistant/components/gatus/quality_scale.yaml b/homeassistant/components/gatus/quality_scale.yaml index ed597d3a37ee2..a5dadd68ea642 100644 --- a/homeassistant/components/gatus/quality_scale.yaml +++ b/homeassistant/components/gatus/quality_scale.yaml @@ -67,9 +67,7 @@ rules: entity-disabled-by-default: status: exempt comment: All entities represent monitored services and should be enabled by default. - entity-translations: - status: exempt - comment: Entity names are dynamically provided by the Gatus service. + entity-translations: done exception-translations: done icon-translations: done reconfiguration-flow: done diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index 22fc0bdd72223..2a18c8dcb256e 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==9.1.1", "oauth2client==4.1.3", "ical==14.1.1"] + "requirements": ["gcal-sync==9.1.1", "oauth2client==4.1.3", "ical==14.2.0"] } diff --git a/homeassistant/components/hive/binary_sensor.py b/homeassistant/components/hive/binary_sensor.py index fc931bebff058..3393924ba4f18 100644 --- a/homeassistant/components/hive/binary_sensor.py +++ b/homeassistant/components/hive/binary_sensor.py @@ -42,7 +42,7 @@ ), BinarySensorEntityDescription( key="GLASS_BREAK", - device_class=BinarySensorDeviceClass.SOUND, + device_class=BinarySensorDeviceClass.GLASS_BREAK, ), ) diff --git a/homeassistant/components/homeassistant/llm.py b/homeassistant/components/homeassistant/llm.py index 2dc81095cf5c0..9272f2d79bd49 100644 --- a/homeassistant/components/homeassistant/llm.py +++ b/homeassistant/components/homeassistant/llm.py @@ -7,6 +7,7 @@ import probatio +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.llm import LLMTools from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, @@ -45,7 +46,7 @@ DYNAMIC_CONTEXT_PROMPT = ( "You ARE equipped to answer questions about the" " current state of\n" - "the home using the `homeassistant__GetLiveContext` tool." + "the home by retrieving live context." " This is a primary function." " Do not state you lack the\n" "functionality if the question requires live data.\n" @@ -59,7 +60,7 @@ ' "What mode is the thermostat in?",' ' "What is the temperature outside?"):\n' " 1. Recognize this requires live data.\n" - " 2. You MUST call `homeassistant__GetLiveContext`." + " 2. You MUST use the provided tool to retrieve live context." " This tool will provide the needed real-time" " information (like temperature from the local" " weather, lock status, etc.).\n" @@ -172,6 +173,14 @@ def async_get_exposed_entities( if attr_name in interesting_attributes } ): + # Tools take brightness as a 0-100 percentage; the attribute is 0-255. + if state.domain == LIGHT_DOMAIN and isinstance( + brightness := state.attributes.get("brightness"), int + ): + pct = round(brightness / 255 * 100) + attributes["brightness_pct"] = str( + max(pct, 1) if brightness > 0 else pct + ) info["attributes"] = attributes entities[state.entity_id] = info diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index 5fe9605f80c59..a4698c6e5d18b 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -562,7 +562,7 @@ class HomematicipFullFlushLockControllerGlassBreak( ): """Representation of the HomematicIP full flush lock controller glass state.""" - _attr_device_class = BinarySensorDeviceClass.PROBLEM + _attr_device_class = BinarySensorDeviceClass.GLASS_BREAK def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the full flush lock controller glass break sensor.""" diff --git a/homeassistant/components/icloud/account.py b/homeassistant/components/icloud/account.py index 0433ca936c21a..189da4b39ff40 100644 --- a/homeassistant/components/icloud/account.py +++ b/homeassistant/components/icloud/account.py @@ -175,6 +175,11 @@ def update_devices(self) -> None: api_devices = {} try: api_devices = self.api.devices + # Since pyicloud 2.3.0 device reads are cache-only and the library + # requests an active locate from Apple only at service creation, so + # explicitly refresh with locate=True to get a fresh GPS fix on + # every poll instead of Apple's cached location. + api_devices.refresh(locate=True) except Exception as err: # noqa: BLE001 _LOGGER.error("Unknown iCloud error: %s", err) self._fetch_interval = 2 diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index 58776c82aaecd..6c8b744200570 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==14.1.1"] + "requirements": ["ical==14.2.0"] } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index 622d427924aef..2b5e7ce522c66 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==14.1.1"] + "requirements": ["ical==14.2.0"] } diff --git a/homeassistant/components/matter/fan.py b/homeassistant/components/matter/fan.py index 4981f7aadc4c5..5439b1105fbda 100644 --- a/homeassistant/components/matter/fan.py +++ b/homeassistant/components/matter/fan.py @@ -270,9 +270,13 @@ def _calculate_features( # does not leave a stale speed_count / percentage_step. self._attr_speed_count = 100 if feature_map & FanControlFeature.kMultiSpeed: - self._attr_speed_count = int( + speed_max = int( self.get_matter_attribute_value(clusters.FanControl.Attributes.SpeedMax) ) + # the step size divides by the speed count, so a device reporting + # no speeds keeps the default + if speed_max > 0: + self._attr_speed_count = speed_max if feature_map & FanControlFeature.kRocking: # NOTE: the Matter model allows that a device can have multiple/different # rock directions while HA doesn't allow this in the entity model. diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index 97d601c835b10..206db521b9a44 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -84,6 +84,19 @@ ) +def _level_range(level_control: clusters.LevelControl) -> tuple[int, int]: + """Return the level range of the device. + + Brightness scaling divides by the width of the range, so a device that + reports a range without width gets the default range instead. + """ + min_level = level_control.minLevel or 1 + max_level = level_control.maxLevel or 254 + if max_level <= min_level: + return (1, 254) + return (min_level, max_level) + + async def async_setup_entry( hass: HomeAssistant, config_entry: MatterConfigEntry, @@ -177,13 +190,7 @@ async def _set_brightness(self, brightness: int, transition: float = 0.0) -> Non assert level_control is not None - level = round( - renormalize( - brightness, - (0, 255), - (level_control.minLevel or 1, level_control.maxLevel or 254), - ) - ) + level = round(renormalize(brightness, (0, 255), _level_range(level_control))) await self.send_device_command( clusters.LevelControl.Commands.MoveToLevelWithOnOff( @@ -280,9 +287,7 @@ def _get_brightness(self) -> int | None: return round( renormalize( - level_control.currentLevel, - (level_control.minLevel or 1, level_control.maxLevel or 254), - (0, 255), + level_control.currentLevel, _level_range(level_control), (0, 255) ) ) diff --git a/homeassistant/components/mcp_server/manifest.json b/homeassistant/components/mcp_server/manifest.json index a70914369e043..df019aef4dc87 100644 --- a/homeassistant/components/mcp_server/manifest.json +++ b/homeassistant/components/mcp_server/manifest.json @@ -8,6 +8,6 @@ "integration_type": "service", "iot_class": "local_push", "quality_scale": "silver", - "requirements": ["mcp==1.28.1", "aiohttp_sse==2.2.0", "anyio==4.13.0"], + "requirements": ["mcp==1.28.1", "aiohttp_sse==2.2.0", "anyio==4.14.2"], "single_config_entry": true } diff --git a/homeassistant/components/midea/manifest.json b/homeassistant/components/midea/manifest.json index 5ee46fc762e1b..2a110ee733a11 100644 --- a/homeassistant/components/midea/manifest.json +++ b/homeassistant/components/midea/manifest.json @@ -12,6 +12,6 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["midealocal"], - "quality_scale": "bronze", - "requirements": ["midea-local==12.0.0"] + "quality_scale": "gold", + "requirements": ["midea-local==12.1.0"] } diff --git a/homeassistant/components/opengarage/manifest.json b/homeassistant/components/opengarage/manifest.json index adc456d8d8bb2..c4d2842f3fe6e 100644 --- a/homeassistant/components/opengarage/manifest.json +++ b/homeassistant/components/opengarage/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["opengarage"], - "requirements": ["open-garage==0.2.0"] + "requirements": ["open-garage==0.3.1"] } diff --git a/homeassistant/components/opentherm_gw/button.py b/homeassistant/components/opentherm_gw/button.py index efd4198aa9432..d40e36450e416 100644 --- a/homeassistant/components/opentherm_gw/button.py +++ b/homeassistant/components/opentherm_gw/button.py @@ -18,6 +18,7 @@ from . import OpenThermGatewayHub from .const import ( + BOILER_DEVICE_DESCRIPTION, DATA_GATEWAYS, DATA_OPENTHERM_GW, GATEWAY_DEVICE_DESCRIPTION, @@ -42,10 +43,17 @@ class OpenThermButtonEntityDescription( device_description=THERMOSTAT_DEVICE_DESCRIPTION, action=lambda hub: hub.set_room_setpoint(0), ), + OpenThermButtonEntityDescription( + key="hot_water_push", + translation_key="hot_water_push", + device_description=BOILER_DEVICE_DESCRIPTION, + action=lambda hub: hub.gateway.set_hot_water_ovrd("P"), + ), OpenThermButtonEntityDescription( key="restart_button", device_class=ButtonDeviceClass.RESTART, device_description=GATEWAY_DEVICE_DESCRIPTION, + entity_category=EntityCategory.CONFIG, action=lambda hub: hub.gateway.set_mode(gw_vars.OTGW_MODE_RESET), ), ) @@ -67,7 +75,6 @@ async def async_setup_entry( class OpenThermButton(OpenThermEntity, ButtonEntity): """Representation of an OpenTherm button.""" - _attr_entity_category = EntityCategory.CONFIG entity_description: OpenThermButtonEntityDescription @override diff --git a/homeassistant/components/opentherm_gw/strings.json b/homeassistant/components/opentherm_gw/strings.json index 7b6ee70e3c62f..3af738b9803ba 100644 --- a/homeassistant/components/opentherm_gw/strings.json +++ b/homeassistant/components/opentherm_gw/strings.json @@ -160,6 +160,9 @@ "button": { "cancel_room_setpoint_override": { "name": "Cancel room setpoint override" + }, + "hot_water_push": { + "name": "Heat hot water once" } }, "select": { diff --git a/homeassistant/components/ourgroceries/manifest.json b/homeassistant/components/ourgroceries/manifest.json index 6d38b1a885878..ff242bc7cab6b 100644 --- a/homeassistant/components/ourgroceries/manifest.json +++ b/homeassistant/components/ourgroceries/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ourgroceries", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["ourgroceries==1.5.4"] + "requirements": ["ourgroceries==1.6.0"] } diff --git a/homeassistant/components/ourgroceries/todo.py b/homeassistant/components/ourgroceries/todo.py index 0241a7bf83d64..4f29f9731b089 100644 --- a/homeassistant/components/ourgroceries/todo.py +++ b/homeassistant/components/ourgroceries/todo.py @@ -1,8 +1,9 @@ """A todo platform for OurGroceries.""" -import asyncio from typing import Any, override +from ourgroceries import make_delete_item_edit_record + from homeassistant.components.todo import ( TodoItem, TodoItemStatus, @@ -109,11 +110,8 @@ async def async_update_todo_item(self, item: TodoItem) -> None: @override async def async_delete_todo_items(self, uids: list[str]) -> None: """Delete a To-do item.""" - await asyncio.gather( - *[ - self.coordinator.og.remove_item_from_list(self._list_id, uid) - for uid in uids - ] + await self.coordinator.og.edit_items( + self._list_id, [make_delete_item_edit_record(uid) for uid in uids] ) await self.coordinator.async_refresh() diff --git a/homeassistant/components/remote_calendar/manifest.json b/homeassistant/components/remote_calendar/manifest.json index e7e53aba98aa9..84fc117b01de5 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==14.1.1"] + "requirements": ["ical==14.2.0"] } diff --git a/homeassistant/components/ring/config_flow.py b/homeassistant/components/ring/config_flow.py index 0a4089d597a47..5150a5953aa6e 100644 --- a/homeassistant/components/ring/config_flow.py +++ b/homeassistant/components/ring/config_flow.py @@ -186,12 +186,14 @@ async def async_step_reauth_confirm( _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - data = { + data_updates = { CONF_USERNAME: user_input[CONF_USERNAME], CONF_TOKEN: token, CONF_DEVICE_ID: self.hardware_id, } - return self.async_update_reload_and_abort(reauth_entry, data=data) + return self.async_update_reload_and_abort( + reauth_entry, data_updates=data_updates + ) return self.async_show_form( step_id="reauth_confirm", @@ -229,12 +231,14 @@ async def async_step_reconfigure( _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - data = { + data_updates = { CONF_USERNAME: username, CONF_TOKEN: token, CONF_DEVICE_ID: self.hardware_id, } - return self.async_update_reload_and_abort(reconfigure_entry, data=data) + return self.async_update_reload_and_abort( + reconfigure_entry, data_updates=data_updates + ) return self.async_show_form( step_id="reconfigure", diff --git a/homeassistant/components/simplisafe/binary_sensor.py b/homeassistant/components/simplisafe/binary_sensor.py index 2d50d85ee55ee..8b32548a5fdbd 100644 --- a/homeassistant/components/simplisafe/binary_sensor.py +++ b/homeassistant/components/simplisafe/binary_sensor.py @@ -43,7 +43,7 @@ TRIGGERED_SENSOR_TYPES = { DeviceTypes.CARBON_MONOXIDE: BinarySensorDeviceClass.GAS, DeviceTypes.ENTRY: BinarySensorDeviceClass.DOOR, - DeviceTypes.GLASS_BREAK: BinarySensorDeviceClass.SAFETY, + DeviceTypes.GLASS_BREAK: BinarySensorDeviceClass.GLASS_BREAK, DeviceTypes.LEAK: BinarySensorDeviceClass.MOISTURE, DeviceTypes.MOTION: BinarySensorDeviceClass.MOTION, DeviceTypes.MOTION_V2: BinarySensorDeviceClass.MOTION, diff --git a/homeassistant/components/smtp/manifest.json b/homeassistant/components/smtp/manifest.json index d2860443d56aa..dbd615deec2b3 100644 --- a/homeassistant/components/smtp/manifest.json +++ b/homeassistant/components/smtp/manifest.json @@ -1,7 +1,7 @@ { "domain": "smtp", "name": "SMTP", - "codeowners": [], + "codeowners": ["@tr4nt0r"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/smtp", "integration_type": "service", diff --git a/homeassistant/components/stiebel_eltron/manifest.json b/homeassistant/components/stiebel_eltron/manifest.json index 29c9fd19563b1..0498f1fb4b561 100644 --- a/homeassistant/components/stiebel_eltron/manifest.json +++ b/homeassistant/components/stiebel_eltron/manifest.json @@ -14,5 +14,5 @@ "iot_class": "local_polling", "loggers": ["pystiebeleltron"], "quality_scale": "silver", - "requirements": ["pystiebeleltron==0.7.0"] + "requirements": ["pystiebeleltron==0.8.0"] } diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index bf826b6526951..21155a3a0a283 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -222,12 +222,21 @@ def _update_state(self, result): def _set_state(_): """Set state of template binary sensor.""" self._attr_is_on = state - self.async_write_ha_state() + if self._preview_callback: + self._async_preview_update() + else: + self.async_write_ha_state() delay = (self._delay_on if state else self._delay_off).total_seconds() # state with delay. Cancelled if template result changes. self._delay_cancel = async_call_later(self.hass, delay, _set_state) + @override + def _call_on_remove_callbacks(self): + if self._delay_cancel: + self._delay_cancel() + return super()._call_on_remove_callbacks() + @dataclass class AutoOffExtraStoredData(ExtraStoredData): diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index 84901194565b2..a3569dbc179dc 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -52,7 +52,11 @@ TemplateCodeFormat, async_create_preview_alarm_control_panel, ) -from .binary_sensor import async_create_preview_binary_sensor +from .binary_sensor import ( + CONF_DELAY_OFF, + CONF_DELAY_ON, + async_create_preview_binary_sensor, +) from .climate import ( CONF_CURRENT_TEMPERATURE, CONF_HVAC_ACTION, @@ -198,6 +202,14 @@ def generate_schema(domain: str, flow_type: str) -> probatio.Schema: selector.DeviceClassSelectorConfig(domain=Platform.BINARY_SENSOR), ), } + additional_options |= { + probatio.Optional(CONF_DELAY_ON): selector.DurationSelector( + selector.DurationSelectorConfig(allow_negative=False) + ), + probatio.Optional(CONF_DELAY_OFF): selector.DurationSelector( + selector.DurationSelectorConfig(allow_negative=False) + ), + } if domain == Platform.BUTTON: schema |= { diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 99bfe69c5301f..dcdb2f50c82c1 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -70,10 +70,14 @@ "sections": { "additional_options": { "data": { - "availability": "[%key:component::template::common::availability%]" + "availability": "[%key:component::template::common::availability%]", + "delay_off": "Delay off", + "delay_on": "Delay on" }, "data_description": { - "availability": "[%key:component::template::common::availability_description%]" + "availability": "[%key:component::template::common::availability_description%]", + "delay_off": "The amount of time the template state must not be met before this sensor switches to `off`.", + "delay_on": "The amount of time the template state must be met before this sensor switches to `on`." }, "name": "[%key:component::template::common::additional_options%]" } @@ -712,10 +716,14 @@ "sections": { "additional_options": { "data": { - "availability": "[%key:component::template::common::availability%]" + "availability": "[%key:component::template::common::availability%]", + "delay_off": "[%key:component::template::config::step::binary_sensor::sections::additional_options::data::delay_off%]", + "delay_on": "[%key:component::template::config::step::binary_sensor::sections::additional_options::data::delay_on%]" }, "data_description": { - "availability": "[%key:component::template::common::availability_description%]" + "availability": "[%key:component::template::common::availability_description%]", + "delay_off": "[%key:component::template::config::step::binary_sensor::sections::additional_options::data_description::delay_off%]", + "delay_on": "[%key:component::template::config::step::binary_sensor::sections::additional_options::data_description::delay_on%]" }, "name": "[%key:component::template::common::additional_options%]" } diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 967a310bf32fc..bd92181ff516a 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -453,6 +453,18 @@ def _handle_results( self._preview_callback(None, None, None, str(errors[-1])) return + self._async_preview_update() + + @callback + def _async_preview_update(self) -> None: + """Send an updated state to the preview callback.""" + if not self._preview_callback: + return + + if not self._template_result_info: + self._preview_callback(None, None, None, "Preview not ready") + return + try: calculated_state = self._async_calculate_state() validate_state(calculated_state.state) diff --git a/homeassistant/components/tesla_fleet/coordinator.py b/homeassistant/components/tesla_fleet/coordinator.py index 0fa6d7e05e9a4..de1806ab01c0f 100644 --- a/homeassistant/components/tesla_fleet/coordinator.py +++ b/homeassistant/components/tesla_fleet/coordinator.py @@ -322,6 +322,8 @@ async def async_config_entry_first_refresh(self) -> None: async def _async_update_data(self) -> dict[str, Any]: """Update energy site history data using Tesla Fleet API.""" + self.update_interval = ENERGY_HISTORY_INTERVAL + try: data = (await self.api.energy_history(TeslaEnergyPeriod.DAY))["response"] except RateLimited as e: diff --git a/homeassistant/components/unifi/coordinator.py b/homeassistant/components/unifi/coordinator.py index cca4293d75661..daee12d4f157d 100644 --- a/homeassistant/components/unifi/coordinator.py +++ b/homeassistant/components/unifi/coordinator.py @@ -3,10 +3,11 @@ from datetime import timedelta from typing import TYPE_CHECKING, override +from aiounifi import EndpointNotFound from aiounifi.interfaces.api_handlers import APIHandler, ItemEvent from homeassistant.core import callback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import LOGGER @@ -25,6 +26,8 @@ def __init__( self, hub: UnifiHub, handler: HandlerT, + *, + disable_polling_on_endpoint_not_found: bool = False, ) -> None: """Initialize coordinator.""" supports_websocket = bool(handler.process_messages or handler.remove_messages) @@ -36,6 +39,10 @@ def __init__( update_interval=None if supports_websocket else POLL_INTERVAL, ) self._handler = handler + self._disable_polling_on_endpoint_not_found = ( + disable_polling_on_endpoint_not_found + ) + self._endpoint_not_found_logged = False hub.config.entry.async_on_unload(handler.subscribe(self._async_handle_update)) @@ -47,7 +54,20 @@ def handler(self) -> HandlerT: @override async def _async_update_data(self) -> None: """Update data from the API handler.""" - await self._handler.update() + try: + await self._handler.update() + except EndpointNotFound as err: + if ( + self._disable_polling_on_endpoint_not_found + and not self._endpoint_not_found_logged + ): + self._endpoint_not_found_logged = True + self.update_interval = None + self.logger.warning( + "UniFi %s endpoint is unavailable; disabling polling", + type(self._handler).__name__, + ) + raise UpdateFailed(str(err)) from err @callback def _async_handle_update(self, event: ItemEvent, obj_id: str) -> None: diff --git a/homeassistant/components/unifi/hub/entity_loader.py b/homeassistant/components/unifi/hub/entity_loader.py index e38db332d6596..462df0f86d13e 100644 --- a/homeassistant/components/unifi/hub/entity_loader.py +++ b/homeassistant/components/unifi/hub/entity_loader.py @@ -53,7 +53,9 @@ def __init__(self, hub: UnifiHub) -> None: hub, hub.api.firewall_policies ), id(hub.api.object_oriented_network_configs): UnifiDataUpdateCoordinator( - hub, hub.api.object_oriented_network_configs + hub, + hub.api.object_oriented_network_configs, + disable_polling_on_endpoint_not_found=True, ), id(hub.api.port_forwarding): UnifiDataUpdateCoordinator( hub, hub.api.port_forwarding diff --git a/homeassistant/components/unifiprotect/alarm_control_panel.py b/homeassistant/components/unifiprotect/alarm_control_panel.py index b9316cc2669f0..472aa43a4c750 100644 --- a/homeassistant/components/unifiprotect/alarm_control_panel.py +++ b/homeassistant/components/unifiprotect/alarm_control_panel.py @@ -107,8 +107,9 @@ def _refresh_alarm_state(self) -> None: self._attr_alarm_state = None return # arm_mode is delivered over the public devices websocket, so - # availability tracks the public WS health (like relay/siren), not the - # private connection the base class would otherwise apply for the NVR. + # availability tracks the public WS health, not the private connection + # the base class would otherwise apply for the NVR. The NVR carries no + # device state, so there is nothing else to gate on. self._attr_available = self.data.last_public_update_success # Fall back to DISARMED for unknown future status values rather than # rendering the entity as ``unknown``. diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 8dd233c2184ee..c00388d0f2c3c 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -570,7 +570,10 @@ class ProtectFobEntity(Entity): ``ProtectApiClient.public_bootstrap.fobs`` and is refreshed over the public devices websocket, so it does not use the private-device machinery in :class:`BaseProtectEntity`. Availability follows the public websocket health - and the fob's presence in the bootstrap, mirroring the relay switch. + and the fob's presence in the bootstrap. Unlike every other public device it + deliberately ignores ``state``: Protect models a fob's reachability as + ``away_state``, which the status sensor surfaces, so gating on ``state`` + would take that sensor away exactly when it has something to report. Subclasses fed by the events websocket set ``_ufp_requires_events_ws`` so they also go unavailable when that stream drops. """ diff --git a/homeassistant/components/unifiprotect/siren.py b/homeassistant/components/unifiprotect/siren.py index 5b6cabfa928b8..7f22fe2be4106 100644 --- a/homeassistant/components/unifiprotect/siren.py +++ b/homeassistant/components/unifiprotect/siren.py @@ -4,7 +4,7 @@ import logging from typing import Any, override -from uiprotect.data import PublicDeviceModel, Siren, SirenDuration +from uiprotect.data import DeviceState, PublicDeviceModel, Siren, SirenDuration from homeassistant.components.siren import ( ATTR_DURATION, @@ -102,7 +102,11 @@ def _siren(self) -> Siren | None: @callback def _update_from_siren(self, siren: Siren) -> None: """Refresh cached attributes from the siren object.""" - self._attr_available = self.data.last_public_update_success + # A siren that dropped off the console stays in the bootstrap. + self._attr_available = ( + self.data.last_public_update_success + and siren.state is DeviceState.CONNECTED + ) self._attr_is_on = siren.is_active @callback diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index f35470a5d873b..0fc189d473c97 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -7,6 +7,7 @@ from uiprotect.data import ( Camera, + DeviceState, ModelType, ProtectAdoptableDeviceModel, PublicDeviceModel, @@ -660,7 +661,11 @@ def _update_from_relay(self, relay: Relay) -> None: self._attr_available = False self._attr_is_on = None return - self._attr_available = self.data.last_public_update_success + # A relay that dropped off the console stays in the bootstrap. + self._attr_available = ( + self.data.last_public_update_success + and relay.state is DeviceState.CONNECTED + ) self._attr_is_on = ( _RELAY_STATE_MAP.get(output.state) if output.state is not None else None ) diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index a10d37bebfd64..5b23d3e7179f4 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -1023,6 +1023,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM "sensordriven", "silent", "forcedlevelfour", + "sensoroverride", ], ), ViCareSensorEntityDescription( diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index afdef6212a495..b15bd66a962b6 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -646,6 +646,7 @@ "permanent": "Permanent", "schedule": "Schedule", "sensordriven": "Sensor-driven", + "sensoroverride": "Sensor override", "silent": "Silent", "standby": "[%key:common::state::standby%]" } diff --git a/homeassistant/components/xbox/manifest.json b/homeassistant/components/xbox/manifest.json index 77970becfbd4a..7305fb6a905ce 100644 --- a/homeassistant/components/xbox/manifest.json +++ b/homeassistant/components/xbox/manifest.json @@ -14,7 +14,7 @@ "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["python-xbox==0.2.2"], + "requirements": ["python-xbox==0.3.0"], "ssdp": [ { "manufacturer": "Microsoft Corporation", diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index f3c5b29de59fd..de4c8b735dd27 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -300,7 +300,7 @@ class LegacyDoorStateRepairDescription: # NotificationType 7: Home Security - State Id's 5, 6 (glass breakage) key=NOTIFICATION_HOME_SECURITY, states={5, 6}, - device_class=BinarySensorDeviceClass.SAFETY, + device_class=BinarySensorDeviceClass.GLASS_BREAK, ), NotificationZWaveJSEntityDescription( # NotificationType 7: Home Security - State Id's 7, 8 (motion) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 80c4792156a93..40f3f60abd220 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -982,7 +982,7 @@ async def __async_setup_with_context( with async_start_setup( hass, integration=self.domain, group=self.entry_id, phase=setup_phase ): - result = await component.async_setup_entry(hass, self) + result = await component.async_setup_entry(hass, self) # type: ignore[func-returns-value,assignment] if not isinstance(result, bool): logger.error( # type: ignore[unreachable] @@ -1127,13 +1127,13 @@ async def async_unload( if domain_is_integration: self._async_set_state(hass, ConfigEntryState.UNLOAD_IN_PROGRESS, None) try: - result = await component.async_unload_entry(hass, self) + result = await component.async_unload_entry(hass, self) # type: ignore[func-returns-value] assert isinstance(result, bool) # Only do side effects if we unloaded the integration - if domain_is_integration: - if result: + if domain_is_integration: # type: ignore[unreachable] + if result: # type: ignore[unused-ignore] await self._async_process_on_unload(hass) if hasattr(self, "runtime_data"): object.__delattr__(self, "runtime_data") @@ -1153,7 +1153,7 @@ async def async_unload( hass, ConfigEntryState.FAILED_UNLOAD, str(exc) or "Unknown error" ) return False - return result + return result # type: ignore[unreachable] async def async_remove(self, hass: HomeAssistant) -> None: """Invoke remove callback on component.""" @@ -1281,13 +1281,13 @@ async def async_migrate(self, hass: HomeAssistant) -> bool: ) return False - result = await component.async_migrate_entry(hass, self) + result = await component.async_migrate_entry(hass, self) # type: ignore[func-returns-value] if not isinstance(result, bool): - self.logger.error( # type: ignore[unreachable] + self.logger.error( "%s.async_migrate_entry did not return boolean", self.domain ) return False - if result: + if result: # type: ignore[unreachable] hass.config_entries._async_schedule_save() # noqa: SLF001 return result diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index ed96216781ce3..77f9405ac74e0 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -25,6 +25,7 @@ config_validation as cv, device_registry as dr, floor_registry as fr, + frame, intent, selector, service, @@ -213,9 +214,30 @@ async def async_call_tool(self, tool_input: ToolInput) -> ToolResult: result = await tool.async_call(self.api.hass, tool_input, self.llm_context) if isinstance(result, ToolResult): return result + frame.report_usage( + "returns a JSON object from a tool, which is deprecated; return a " + "ToolResult instead", + breaks_in_ha_version="2027.11.0", + core_behavior=frame.ReportBehavior.ERROR, + core_integration_behavior=frame.ReportBehavior.ERROR, + custom_integration_behavior=frame.ReportBehavior.LOG, + # The tool call has returned, so its frame is gone from the stack. + integration_domain=_tool_integration_domain(tool), + ) return ToolResult(data=result) +def _tool_integration_domain(tool: Tool) -> str | None: + """Return the domain of the integration that provides the tool.""" + while isinstance(tool, NamespacedTool): + tool = tool.tool + module = type(tool).__module__ + for prefix in ("custom_components.", "homeassistant.components."): + if module.startswith(prefix): + return module.removeprefix(prefix).partition(".")[0] + return None + + @dataclass(slots=True, kw_only=True) class API(ABC): """An API to expose to LLMs.""" diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 11a3260646481..d301ce871cf00 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -384,17 +384,17 @@ class ComponentProtocol(Protocol): async def async_setup_entry( self, hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: + ) -> None: """Set up a config entry.""" async def async_unload_entry( self, hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: + ) -> None: """Unload a config entry.""" async def async_migrate_entry( self, hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: + ) -> None: """Migrate an old config entry.""" async def async_remove_entry( diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 691ca079b1a21..5c739ba5f33ea 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -121,7 +121,7 @@ uuid==1000000000.0.0 # even newer versions seem to introduce new issues, it's useful # for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==4.13.0 +anyio==4.14.2 h11==0.16.0 httpcore==1.0.9 diff --git a/pylint/plugins/pylint_home_assistant/checkers/type_hints/const.py b/pylint/plugins/pylint_home_assistant/checkers/type_hints/const.py index 2285ef9f2e8bf..6aa7bbcc3b516 100644 --- a/pylint/plugins/pylint_home_assistant/checkers/type_hints/const.py +++ b/pylint/plugins/pylint_home_assistant/checkers/type_hints/const.py @@ -107,7 +107,7 @@ 0: "HomeAssistant", 1: "ConfigEntry", }, - return_type="bool", + return_type=["bool", "None"], mandatory=True, ), TypeHintMatch( @@ -125,7 +125,7 @@ 0: "HomeAssistant", 1: "ConfigEntry", }, - return_type="bool", + return_type=["bool", "None"], mandatory=True, ), TypeHintMatch( @@ -134,7 +134,7 @@ 0: "HomeAssistant", 1: "ConfigEntry", }, - return_type="bool", + return_type=["bool", "None"], mandatory=True, ), TypeHintMatch( diff --git a/requirements_all.txt b/requirements_all.txt index 6be7c749bc93d..6fca6a30171d5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -558,7 +558,7 @@ anthemav==1.4.2 anthropic==0.108.0 # homeassistant.components.mcp_server -anyio==4.13.0 +anyio==4.14.2 # homeassistant.components.weatherkit apple_weatherkit==1.1.3 @@ -872,7 +872,7 @@ devolo-home-control-api==0.19.1 devolo-plc-api==1.5.1 # homeassistant.components.de_dietrich -diematic-modbus==0.7.5 +diematic-modbus==0.8.0 # homeassistant.components.chacon_dio dio-chacon-wifi-api==1.3.0 @@ -974,7 +974,7 @@ enocean-async==0.4.2 enturclient==0.2.4 # homeassistant.components.environment_canada -env-canada==0.20.0 +env-canada==0.20.3 # homeassistant.components.season ephem==4.1.6 @@ -1369,7 +1369,7 @@ ibeacon-ble==1.2.0 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==14.1.1 +ical==14.2.0 # homeassistant.components.caldav icalendar==6.3.1 @@ -1635,7 +1635,7 @@ micloud==0.5 microBeesPy==0.3.5 # homeassistant.components.midea -midea-local==12.0.0 +midea-local==12.1.0 # homeassistant.components.mill mill-local==0.5.0 @@ -1825,7 +1825,7 @@ onvif-zeep-async==4.2.1 onvif_parsers==2.3.0 # homeassistant.components.opengarage -open-garage==0.2.0 +open-garage==0.3.1 # homeassistant.components.open_meteo open-meteo==0.3.2 @@ -1875,7 +1875,7 @@ orvibo==1.1.2 ouman-eh-800-api==1.0.0 # homeassistant.components.ourgroceries -ourgroceries==1.5.4 +ourgroceries==1.6.0 # homeassistant.components.ovo_energy ovoenergy==3.0.2 @@ -2681,7 +2681,7 @@ pyspeex-noise==1.0.2 pysqueezebox==0.14.0 # homeassistant.components.stiebel_eltron -pystiebeleltron==0.7.0 +pystiebeleltron==0.8.0 # homeassistant.components.suez_water pysuezV2==2.0.7 @@ -2847,7 +2847,7 @@ python-technove==2.1.3 python-telegram-bot[socks]==22.7 # homeassistant.components.xbox -python-xbox==0.2.2 +python-xbox==0.3.0 # homeassistant.components.egardia pythonegardia==1.0.52 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index ea12c30d2ddb5..4782ecea897d5 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -105,7 +105,7 @@ # even newer versions seem to introduce new issues, it's useful # for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==4.13.0 +anyio==4.14.2 h11==0.16.0 httpcore==1.0.9 diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index b0c1e1594de7d..db121e219e7cf 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -107,14 +107,18 @@ def _generate_machine_dockerfile( apk add --no-cache libturbojpeg \ # Install uv at the version pinned in the requirements file && pip install --no-cache-dir "uv==$(awk -F'==' '/^uv==/{{print $2}}' /usr/src/homeassistant/requirements.txt)" \ - && uv pip install \ + # Install CI-only tooling from PyPI, bypassing the Home Assistant wheel index + && UV_EXTRA_INDEX_URL="" uv pip install \ --no-build \ --no-cache \ - -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ - -r /usr/src/homeassistant/requirements.txt \ "pipdeptree==$(awk -F'==' '/^pipdeptree==/{{print $2}}' /tmp/requirements_test.txt)" \ "tqdm==$(awk -F'==' '/^tqdm==/{{print $2}}' /tmp/requirements_test.txt)" \ - "ruff==$(awk -F'==' '/^ruff==/{{print $2}}' /tmp/requirements_test_pre_commit.txt)" + "ruff==$(awk -F'==' '/^ruff==/{{print $2}}' /tmp/requirements_test_pre_commit.txt)" \ + && uv pip install \ + --no-build \ + --no-cache \ + -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ + -r /usr/src/homeassistant/requirements.txt LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 0d808eb8ae601..4024b9d1896b1 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -22,14 +22,18 @@ RUN --mount=type=tmpfs,target=/tmp \ apk add --no-cache libturbojpeg \ # Install uv at the version pinned in the requirements file && pip install --no-cache-dir "uv==$(awk -F'==' '/^uv==/{print $2}' /usr/src/homeassistant/requirements.txt)" \ - && uv pip install \ + # Install CI-only tooling from PyPI, bypassing the Home Assistant wheel index + && UV_EXTRA_INDEX_URL="" uv pip install \ --no-build \ --no-cache \ - -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ - -r /usr/src/homeassistant/requirements.txt \ "pipdeptree==$(awk -F'==' '/^pipdeptree==/{print $2}' /tmp/requirements_test.txt)" \ "tqdm==$(awk -F'==' '/^tqdm==/{print $2}' /tmp/requirements_test.txt)" \ - "ruff==$(awk -F'==' '/^ruff==/{print $2}' /tmp/requirements_test_pre_commit.txt)" + "ruff==$(awk -F'==' '/^ruff==/{print $2}' /tmp/requirements_test_pre_commit.txt)" \ + && uv pip install \ + --no-build \ + --no-cache \ + -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ + -r /usr/src/homeassistant/requirements.txt LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 057175bd16f7e..8c8bd084f5ec6 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -79,10 +79,6 @@ # Current has an upper bound on major >=3.11.0,<4.0.0 "pysmarty2": {"pymodbus"} }, - "stiebel_eltron": { - # Current has an upper bound on major >=3.10.0,<4.0.0 - "pystiebeleltron": {"pymodbus"} - }, "telegram_bot": {"python-telegram-bot": {"httpx"}}, "xiaomi_miio": { "python-miio": {"zeroconf"}, diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index b1b409ab052a3..78dd9828b4b91 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -433,7 +433,7 @@ async def test_function_call( mock_tool.parameters = probatio.Schema( {probatio.Optional("param1", description="Test parameters"): str} ) - mock_tool.async_call.return_value = "Test response" + mock_tool.async_call.return_value = llm.ToolResult(data="Test response") mock_get_tools.return_value = LLMTools(tools=[mock_tool]) @@ -958,7 +958,7 @@ async def test_extended_thinking_tool_call( mock_tool.parameters = probatio.Schema( {probatio.Optional("param1", description="Test parameters"): str} ) - mock_tool.async_call.return_value = "Test response" + mock_tool.async_call.return_value = llm.ToolResult(data="Test response") mock_get_tools.return_value = LLMTools(tools=[mock_tool]) diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index 99044dd9437f0..c24cab61953ad 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -1869,7 +1869,7 @@ async def stream_llm_response(): mock_tool.name = "test_tool" mock_tool.description = "Test function" mock_tool.parameters = probatio.Schema({}) - mock_tool.async_call.return_value = "Test response" + mock_tool.async_call.return_value = llm.ToolResult(data="Test response") with ( patch( diff --git a/tests/components/blink/test_init.py b/tests/components/blink/test_init.py index 6bb6f21d1669f..484aafe4f4b53 100644 --- a/tests/components/blink/test_init.py +++ b/tests/components/blink/test_init.py @@ -76,6 +76,29 @@ async def test_unload_entry( assert hass.services.has_service(DOMAIN, SERVICE_SAVE_VIDEO) +async def test_scheduled_refresh_is_not_forced( + hass: HomeAssistant, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a scheduled poll does not force a cache refresh. + + BlinkPy propagates a forced refresh as ``force_cache=True`` to the sync + modules, which re-downloads media that is already cached. See #182552. + """ + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_blink_api.refresh.reset_mock() + coordinator = mock_config_entry.runtime_data + await coordinator.async_refresh() + await hass.async_block_till_done() + + mock_blink_api.refresh.assert_awaited_once_with() + + async def test_migrate_V0( hass: HomeAssistant, mock_blink_api: MagicMock, diff --git a/tests/components/brands/test_init.py b/tests/components/brands/test_init.py index 5e13a9bf909db..6eb4ccebd7e19 100644 --- a/tests/components/brands/test_init.py +++ b/tests/components/brands/test_init.py @@ -26,6 +26,7 @@ from tests.typing import ClientSessionGenerator, WebSocketGenerator FAKE_PNG = b"\x89PNG\r\n\x1a\nfakeimagedata" +BRAND_PNG = b"\x89PNG\r\n\x1a\nfakebranddata" @pytest.fixture(autouse=True) @@ -73,7 +74,7 @@ async def test_integration_view_serves_from_cdn( ) -> None: """Test serving an integration brand image from the CDN.""" aioclient_mock.get( - f"{BRANDS_CDN_URL}/brands/hue/icon.png", + f"{BRANDS_CDN_URL}/hue/icon.png", content=FAKE_PNG, ) @@ -85,12 +86,68 @@ async def test_integration_view_serves_from_cdn( assert await resp.read() == FAKE_PNG +async def test_integration_view_prefers_integration_over_brand( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test that the integration image wins over a brand image of the same name.""" + aioclient_mock.get( + f"{BRANDS_CDN_URL}/google/icon.png", + content=FAKE_PNG, + ) + aioclient_mock.get( + f"{BRANDS_CDN_URL}/brands/google/icon.png", + content=BRAND_PNG, + ) + + client = await hass_client() + resp = await client.get("/api/brands/integration/google/icon.png") + + assert resp.status == HTTPStatus.OK + assert await resp.read() == FAKE_PNG + assert aioclient_mock.call_count == 1 + + +async def test_integration_view_falls_back_to_brand( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test that a brand-only domain is served from the brand namespace.""" + aioclient_mock.get( + f"{BRANDS_CDN_URL}/apple/icon.png", + status=HTTPStatus.NOT_FOUND, + ) + aioclient_mock.get( + f"{BRANDS_CDN_URL}/brands/apple/icon.png", + content=BRAND_PNG, + ) + + client = await hass_client() + resp = await client.get("/api/brands/integration/apple/icon.png") + + assert resp.status == HTTPStatus.OK + assert await resp.read() == BRAND_PNG + assert aioclient_mock.call_count == 2 + + # The integration 404 marker and the brand image are cached separately + resp = await client.get("/api/brands/integration/apple/icon.png") + assert resp.status == HTTPStatus.OK + assert await resp.read() == BRAND_PNG + assert aioclient_mock.call_count == 2 + + async def test_integration_view_default_placeholder_fallback( hass: HomeAssistant, hass_client: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, ) -> None: """Test that CDN 404 serves placeholder by default.""" + aioclient_mock.get( + f"{BRANDS_CDN_URL}/nonexistent/icon.png", + status=HTTPStatus.NOT_FOUND, + ) aioclient_mock.get( f"{BRANDS_CDN_URL}/brands/nonexistent/icon.png", status=HTTPStatus.NOT_FOUND, @@ -113,6 +170,10 @@ async def test_integration_view_no_placeholder( aioclient_mock: AiohttpClientMocker, ) -> None: """Test that CDN 404 returns 404 when placeholder=no is set.""" + aioclient_mock.get( + f"{BRANDS_CDN_URL}/nonexistent/icon.png", + status=HTTPStatus.NOT_FOUND, + ) aioclient_mock.get( f"{BRANDS_CDN_URL}/brands/nonexistent/icon.png", status=HTTPStatus.NOT_FOUND, @@ -187,7 +248,7 @@ async def test_integration_view_all_allowed_images( ] for image in allowed: aioclient_mock.get( - f"{BRANDS_CDN_URL}/brands/hue/{image}", + f"{BRANDS_CDN_URL}/hue/{image}", content=FAKE_PNG, ) @@ -203,6 +264,10 @@ async def test_integration_view_cdn_error_returns_none( aioclient_mock: AiohttpClientMocker, ) -> None: """Test that CDN connection errors result in 404 with placeholder=no.""" + aioclient_mock.get( + f"{BRANDS_CDN_URL}/broken/icon.png", + exc=ClientError(), + ) aioclient_mock.get( f"{BRANDS_CDN_URL}/brands/broken/icon.png", exc=ClientError(), @@ -220,6 +285,10 @@ async def test_integration_view_cdn_unexpected_status( aioclient_mock: AiohttpClientMocker, ) -> None: """Test that unexpected CDN status codes result in 404 with placeholder=no.""" + aioclient_mock.get( + f"{BRANDS_CDN_URL}/broken/icon.png", + status=HTTPStatus.INTERNAL_SERVER_ERROR, + ) aioclient_mock.get( f"{BRANDS_CDN_URL}/brands/broken/icon.png", status=HTTPStatus.INTERNAL_SERVER_ERROR, @@ -243,7 +312,7 @@ async def test_disk_cache_hit( ) -> None: """Test that a second request is served from disk cache.""" aioclient_mock.get( - f"{BRANDS_CDN_URL}/brands/hue/icon.png", + f"{BRANDS_CDN_URL}/hue/icon.png", content=FAKE_PNG, ) @@ -267,6 +336,10 @@ async def test_disk_cache_404_marker( aioclient_mock: AiohttpClientMocker, ) -> None: """Test that 404s are cached as empty files.""" + aioclient_mock.get( + f"{BRANDS_CDN_URL}/nothing/icon.png", + status=HTTPStatus.NOT_FOUND, + ) aioclient_mock.get( f"{BRANDS_CDN_URL}/brands/nothing/icon.png", status=HTTPStatus.NOT_FOUND, @@ -274,15 +347,16 @@ async def test_disk_cache_404_marker( client = await hass_client() - # First request: CDN returns 404, cached as empty file + # First request: CDN returns 404 for both namespaces, each cached as an + # empty file resp = await client.get("/api/brands/integration/nothing/icon.png?placeholder=no") assert resp.status == HTTPStatus.NOT_FOUND - assert aioclient_mock.call_count == 1 + assert aioclient_mock.call_count == 2 - # Second request: served from cached 404 marker + # Second request: served from cached 404 markers resp = await client.get("/api/brands/integration/nothing/icon.png?placeholder=no") assert resp.status == HTTPStatus.NOT_FOUND - assert aioclient_mock.call_count == 1 # No additional CDN call + assert aioclient_mock.call_count == 2 # No additional CDN call async def test_stale_cache_triggers_background_refresh( @@ -292,7 +366,7 @@ async def test_stale_cache_triggers_background_refresh( ) -> None: """Test that stale cache entries trigger background refresh.""" aioclient_mock.get( - f"{BRANDS_CDN_URL}/brands/hue/icon.png", + f"{BRANDS_CDN_URL}/hue/icon.png", content=FAKE_PNG, ) @@ -330,6 +404,10 @@ async def test_stale_cache_404_marker_with_placeholder( aioclient_mock: AiohttpClientMocker, ) -> None: """Test that stale cached 404 serves placeholder by default.""" + aioclient_mock.get( + f"{BRANDS_CDN_URL}/gone/icon.png", + status=HTTPStatus.NOT_FOUND, + ) aioclient_mock.get( f"{BRANDS_CDN_URL}/brands/gone/icon.png", status=HTTPStatus.NOT_FOUND, @@ -344,7 +422,7 @@ async def test_stale_cache_404_marker_with_placeholder( # First request caches the 404 (with placeholder=no) resp = await client.get("/api/brands/integration/gone/icon.png?placeholder=no") assert resp.status == HTTPStatus.NOT_FOUND - assert aioclient_mock.call_count == 1 + assert aioclient_mock.call_count == 2 # Make the cache stale cache_path = ( @@ -366,6 +444,10 @@ async def test_stale_cache_404_marker_no_placeholder( aioclient_mock: AiohttpClientMocker, ) -> None: """Test that stale cached 404 with placeholder=no returns 404.""" + aioclient_mock.get( + f"{BRANDS_CDN_URL}/gone/icon.png", + status=HTTPStatus.NOT_FOUND, + ) aioclient_mock.get( f"{BRANDS_CDN_URL}/brands/gone/icon.png", status=HTTPStatus.NOT_FOUND, @@ -376,7 +458,7 @@ async def test_stale_cache_404_marker_no_placeholder( # First request caches the 404 resp = await client.get("/api/brands/integration/gone/icon.png?placeholder=no") assert resp.status == HTTPStatus.NOT_FOUND - assert aioclient_mock.call_count == 1 + assert aioclient_mock.call_count == 2 # Make the cache stale cache_path = ( @@ -390,9 +472,9 @@ async def test_stale_cache_404_marker_no_placeholder( resp = await client.get("/api/brands/integration/gone/icon.png?placeholder=no") assert resp.status == HTTPStatus.NOT_FOUND - # Background refresh should have been triggered + # Background refresh should have been triggered for the stale marker await hass.async_block_till_done() - assert aioclient_mock.call_count == 2 + assert aioclient_mock.call_count == 3 # ------------------------------------------------------------------ @@ -435,7 +517,7 @@ async def test_custom_integration_no_brand_falls_through( custom = _create_custom_integration(hass, "my_custom", has_branding=False) aioclient_mock.get( - f"{BRANDS_CDN_URL}/brands/my_custom/icon.png", + f"{BRANDS_CDN_URL}/my_custom/icon.png", content=FAKE_PNG, ) @@ -463,7 +545,7 @@ async def test_custom_integration_brand_missing_file_falls_through( brand_dir.mkdir(parents=True, exist_ok=True) aioclient_mock.get( - f"{BRANDS_CDN_URL}/brands/my_custom/icon.png", + f"{BRANDS_CDN_URL}/my_custom/icon.png", content=FAKE_PNG, ) @@ -488,7 +570,7 @@ async def test_custom_integration_takes_priority_over_cache( # Prime the CDN cache first aioclient_mock.get( - f"{BRANDS_CDN_URL}/brands/my_custom/icon.png", + f"{BRANDS_CDN_URL}/my_custom/icon.png", content=FAKE_PNG, ) @@ -672,7 +754,7 @@ async def test_custom_integration_no_fallback_match_falls_through_to_cdn( # brand dir exists but is empty - no icon.png either aioclient_mock.get( - f"{BRANDS_CDN_URL}/brands/my_custom/icon.png", + f"{BRANDS_CDN_URL}/my_custom/icon.png", content=FAKE_PNG, ) @@ -757,6 +839,10 @@ async def test_cdn_timeout_returns_404( aioclient_mock: AiohttpClientMocker, ) -> None: """Test that CDN timeout results in 404 with placeholder=no.""" + aioclient_mock.get( + f"{BRANDS_CDN_URL}/slow/icon.png", + exc=TimeoutError(), + ) aioclient_mock.get( f"{BRANDS_CDN_URL}/brands/slow/icon.png", exc=TimeoutError(), @@ -780,7 +866,7 @@ async def test_authenticated_request( ) -> None: """Test that authenticated requests succeed.""" aioclient_mock.get( - f"{BRANDS_CDN_URL}/brands/hue/icon.png", + f"{BRANDS_CDN_URL}/hue/icon.png", content=FAKE_PNG, ) @@ -797,7 +883,7 @@ async def test_token_query_param_authentication( ) -> None: """Test that a valid access token in query param authenticates.""" aioclient_mock.get( - f"{BRANDS_CDN_URL}/brands/hue/icon.png", + f"{BRANDS_CDN_URL}/hue/icon.png", content=FAKE_PNG, ) @@ -857,7 +943,7 @@ async def test_token_rotation( ) -> None: """Test that access tokens rotate over time.""" aioclient_mock.get( - f"{BRANDS_CDN_URL}/brands/hue/icon.png", + f"{BRANDS_CDN_URL}/hue/icon.png", content=FAKE_PNG, ) diff --git a/tests/components/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py index 249388456b71b..72b505daf6dc7 100644 --- a/tests/components/conversation/test_chat_log.py +++ b/tests/components/conversation/test_chat_log.py @@ -439,7 +439,7 @@ async def test_tool_call( mock_tool.parameters = probatio.Schema( {probatio.Optional("param1", description="Test parameters"): str} ) - mock_tool.async_call.return_value = "Test response" + mock_tool.async_call.return_value = llm.ToolResult(data="Test response") with ( patch( @@ -704,9 +704,9 @@ async def test_add_delta_content_stream( async def tool_call( hass: HomeAssistant, tool_input: llm.ToolInput, llm_context: llm.LLMContext - ) -> str: + ) -> llm.ToolResult: """Call the tool.""" - return tool_input.tool_args["param1"] + return llm.ToolResult(data=tool_input.tool_args["param1"]) mock_tool.async_call.side_effect = tool_call expected_delta = [] @@ -1042,8 +1042,9 @@ def event_callback( assert len(received_events) == events_before_unsubscribe +@pytest.mark.usefixtures("mock_integration_frame") async def test_tool_result_content_deprecated_property() -> None: - """Test the deprecated tool_result property returns the result data.""" + """Test reading the deprecated tool_result property is reported.""" content = ToolResultContent( agent_id="mock-agent-id", tool_call_id="mock-tool-call-id", @@ -1051,14 +1052,16 @@ async def test_tool_result_content_deprecated_property() -> None: result=llm.ToolResult(data={"answer": 42}), ) - assert content.tool_result == {"answer": 42} + with pytest.raises(RuntimeError, match="ToolResultContent.tool_result"): + _ = content.tool_result +@pytest.mark.usefixtures("mock_integration_frame") async def test_add_delta_content_stream_deprecated_tool_result( hass: HomeAssistant, mock_conversation_input: ConversationInput, ) -> None: - """Test a delta carrying the deprecated tool_result key is still accepted.""" + """Test setting the deprecated tool_result key on a delta is reported.""" async def stream(): """Yield a tool result delta using the deprecated key.""" @@ -1072,12 +1075,11 @@ async def stream(): with ( chat_session.async_get_chat_session(hass) as session, async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + pytest.raises(RuntimeError, match="tool result delta"), ): - results = [ + _ = [ content async for content in chat_log.async_add_delta_content_stream( "mock-agent-id", stream() ) ] - - assert results[0].result == llm.ToolResult(data={"answer": 42}) diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 54732b1649595..5ff00c24323c8 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -3520,7 +3520,7 @@ async def test_intent_tool_call_in_chat_log(hass: HomeAssistant) -> None: # Verify tool result was stored assert tool_result_content is not None assert tool_result_content.tool_name == "HassTurnOn" - assert tool_result_content.tool_result["response_type"] == "action_done" + assert tool_result_content.result.data["response_type"] == "action_done" # Verify final assistant content with speech assert assistant_content is not None @@ -3569,7 +3569,7 @@ async def test_trigger_tool_call_in_chat_log(hass: HomeAssistant) -> None: # Verify tool result was stored assert tool_result_content is not None assert tool_result_content.tool_name == "trigger_sentence" - assert tool_result_content.tool_result["response"] == trigger_response + assert tool_result_content.result.data["response"] == trigger_response @pytest.mark.usefixtures("init_components") diff --git a/tests/components/cover/test_intent.py b/tests/components/cover/test_intent.py index 1af3d51200126..a8ec75038c0c7 100644 --- a/tests/components/cover/test_intent.py +++ b/tests/components/cover/test_intent.py @@ -83,6 +83,32 @@ async def test_close_cover_intent(hass: HomeAssistant, slots: dict[str, Any]) -> assert call.data == {"entity_id": f"{DOMAIN}.garage_door"} +@pytest.mark.parametrize( + ("intent_type", "initial_state"), + [ + (cover_intent.INTENT_OPEN_COVER, "off"), + (cover_intent.INTENT_CLOSE_COVER, "on"), + ], +) +async def test_cover_intent_does_not_match_other_domains( + hass: HomeAssistant, intent_type: str, initial_state: str +) -> None: + """Test HassOpenCover/HassCloseCover do not match an entity of another domain.""" + await cover_intent.async_setup_intents(hass) + + hass.states.async_set("light.garage_door", initial_state) + + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent_type, + {"name": {"value": "garage door"}}, + ) + + assert err.value.result.no_match_reason == intent.MatchFailedReason.DOMAIN + + @pytest.mark.parametrize( ("slots"), [ diff --git a/tests/components/devolo_home_network/test_device_tracker.py b/tests/components/devolo_home_network/test_device_tracker.py index 86ce0122c2e6b..d278c47148381 100644 --- a/tests/components/devolo_home_network/test_device_tracker.py +++ b/tests/components/devolo_home_network/test_device_tracker.py @@ -95,3 +95,28 @@ async def test_restoring_clients( state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_NOT_HOME + + +@pytest.mark.usefixtures("mock_device", "entity_registry_enabled_by_default") +async def test_recreating_removed_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that the entity registry is the source of truth for tracked clients.""" + entity_id = ( + f"{DEVICE_TRACKER_DOMAIN}.{STATION.mac_address.lower().replace(':', '_')}" + ) + entry = configure_integration(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entity_registry.async_get(entity_id) is not None + + entity_registry.async_remove(entity_id) + await hass.async_block_till_done() + assert entity_registry.async_get(entity_id) is None + + freezer.tick(SHORT_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert entity_registry.async_get(entity_id) is not None diff --git a/tests/components/dlink/test_switch.py b/tests/components/dlink/test_switch.py index 0460a6a918f3d..7069a03c75d70 100644 --- a/tests/components/dlink/test_switch.py +++ b/tests/components/dlink/test_switch.py @@ -34,6 +34,7 @@ async def test_switch_state(hass: HomeAssistant, mocked_plug: AsyncMock) -> None assert state.state == STATE_OFF assert state.attributes["total_consumption"] == 1040.0 assert state.attributes["temperature"] == 33 + assert state.attributes["current_consumption"] == 50.0 await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, @@ -67,3 +68,4 @@ async def test_switch_no_value( assert state.state == STATE_OFF assert state.attributes["total_consumption"] is None assert state.attributes["temperature"] is None + assert state.attributes["current_consumption"] is None diff --git a/tests/components/ecobee/test_switch.py b/tests/components/ecobee/test_switch.py index 73b57b84b90e8..4815f55404190 100644 --- a/tests/components/ecobee/test_switch.py +++ b/tests/components/ecobee/test_switch.py @@ -1,9 +1,10 @@ """The test for the ecobee thermostat switch module.""" import copy -from datetime import datetime, timedelta +from datetime import timedelta from unittest import mock from unittest.mock import patch +from zoneinfo import ZoneInfo import pytest @@ -15,12 +16,16 @@ ) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from . import GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP from .common import setup_platform VENTILATOR_20MIN_ID = "switch.ecobee_ventilator_20m_timer" THERMOSTAT_ID = 0 +THERMOSTAT_TIMEZONE = ZoneInfo( + GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP["location"]["timeZone"] +) @pytest.fixture(name="data") @@ -43,7 +48,7 @@ async def test_ventilator_20min_when_on(hass: HomeAssistant, data) -> None: """Test the ventilator switch goes on.""" data.return_value["settings"]["ventilatorOffDateTime"] = ( - datetime.now() + timedelta(days=1) # pylint: disable=home-assistant-enforce-naive-now + dt_util.now(THERMOSTAT_TIMEZONE) + timedelta(days=1) ).strftime(DATE_FORMAT) with mock.patch("pyecobee.Ecobee.get_thermostat", data): await setup_platform(hass, SWITCH_DOMAIN) @@ -58,7 +63,7 @@ async def test_ventilator_20min_when_off(hass: HomeAssistant, data) -> None: """Test the ventilator switch goes on.""" data.return_value["settings"]["ventilatorOffDateTime"] = ( - datetime.now() - timedelta(days=1) # pylint: disable=home-assistant-enforce-naive-now + dt_util.now(THERMOSTAT_TIMEZONE) - timedelta(days=1) ).strftime(DATE_FORMAT) with mock.patch("pyecobee.Ecobee.get_thermostat", data): await setup_platform(hass, SWITCH_DOMAIN) diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index dfc0000c21251..370383c2e4b01 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -1769,6 +1769,36 @@ async def test_only_change_hue_or_saturation( ] == (0, 3) +@pytest.mark.usefixtures("base_setup") +@pytest.mark.parametrize( + ("entity_config", "expected_exposed"), + [ + pytest.param({}, True, id="hidden_omitted"), + pytest.param({emulated_hue.CONF_ENTITY_HIDDEN: False}, True, id="hidden_false"), + pytest.param({emulated_hue.CONF_ENTITY_HIDDEN: True}, False, id="hidden_true"), + ], +) +async def test_listed_entity_exposed_with_expose_by_default_off( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + entity_config: dict[str, bool], + expected_exposed: bool, +) -> None: + """Test an explicitly listed entity is exposed with expose by default off.""" + conf = { + emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT, + emulated_hue.CONF_EXPOSE_BY_DEFAULT: False, + emulated_hue.CONF_ENTITIES: {"light.exposed": entity_config}, + } + await _async_setup_emulated_hue(hass, conf) + _mock_hue_endpoints(hass, conf, {"1": "light.exposed"}) + hass.states.async_set("light.exposed", STATE_ON) + await hass.async_block_till_done() + client = await hass_client_no_auth() + result_json = await async_get_lights(client) + assert bool(result_json) is expected_exposed + + @pytest.mark.usefixtures("base_setup") async def test_specificly_exposed_entities( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator @@ -1797,10 +1827,27 @@ async def test_specificly_exposed_entities( hass.states.async_set("light.exposed", STATE_ON) await hass.async_block_till_done() result_json = await async_get_lights(client) - assert "1" in result_json +@pytest.mark.usefixtures("base_setup") +async def test_unlisted_entity_not_exposed_with_expose_by_default_off( + hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator +) -> None: + """Test an entity not listed in the config is not exposed by default off.""" + conf = { + emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT, + emulated_hue.CONF_EXPOSE_BY_DEFAULT: False, + } + await _async_setup_emulated_hue(hass, conf) + _mock_hue_endpoints(hass, conf, {"1": "light.unlisted"}) + hass.states.async_set("light.unlisted", STATE_ON) + await hass.async_block_till_done() + client = await hass_client_no_auth() + result_json = await async_get_lights(client) + assert not result_json + + async def test_get_light_state_when_none( hass_hue: HomeAssistant, hue_client: TestClient ) -> None: diff --git a/tests/components/fronius/test_modbus.py b/tests/components/fronius/test_modbus.py index 9806d21012819..ae1bca2f58e9b 100644 --- a/tests/components/fronius/test_modbus.py +++ b/tests/components/fronius/test_modbus.py @@ -92,6 +92,54 @@ async def test_gen24_storage_mppt( assert len(solar_net.modbus_inverter_coordinators) == 1 +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_verto_plus_five_modules( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_fronius_modbus: MockModbusConnection, +) -> None: + """Test a Verto Plus exposing 3 PV trackers plus storage charge/discharge.""" + mock_fronius_modbus.for_unit(1).holding.update( + build_sunspec_map( + [ + MpptModuleSpec( + id_str="MPPT 1", current=82, voltage=4021, power=3300, energy=1_000 + ), + MpptModuleSpec( + id_str="MPPT 2", current=41, voltage=4022, power=1650, energy=2_000 + ), + MpptModuleSpec( + id_str="MPPT 3", current=20, voltage=4023, power=800, energy=3_000 + ), + MpptModuleSpec( + id_str="StCha 4", current=0, voltage=0, power=0, energy=4_000 + ), + MpptModuleSpec( + id_str="StDisCha 5", + current=12, + voltage=3990, + power=480, + energy=5_000, + ), + ], + storage_wcha_max=5000, + ) + ) + mock_responses(aioclient_mock, fixture_set="gen24_storage") + with patch("homeassistant.components.fronius.PLATFORMS", [Platform.SENSOR]): + await setup_fronius_integration(hass, is_logger=False, unique_id="12345678") + + assert_state(hass, "sensor.gen24_storage_mppt_3_dc_power", 800) + assert_state(hass, "sensor.gen24_storage_mppt_4_dc_power", 0) + assert_state(hass, "sensor.gen24_storage_mppt_5_dc_current", 1.2) + assert_state(hass, "sensor.gen24_storage_mppt_5_dc_voltage", 399.0) + assert_state(hass, "sensor.gen24_storage_mppt_5_dc_power", 480) + assert_state(hass, "sensor.gen24_storage_mppt_5_energy", 5000) + assert_state(hass, "sensor.gen24_storage_pv_energy_total", 6000) + assert_state(hass, "sensor.gen24_storage_battery_charging_energy_total", 4000) + assert_state(hass, "sensor.gen24_storage_battery_discharging_energy_total", 5000) + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( ("storage_id_str", "expected_pv_energy_total"), diff --git a/tests/components/heos/snapshots/test_media_player.ambr b/tests/components/heos/snapshots/test_media_player.ambr index cc9a4e023a8c7..6ce76f742ae5e 100644 --- a/tests/components/heos/snapshots/test_media_player.ambr +++ b/tests/components/heos/snapshots/test_media_player.ambr @@ -68,7 +68,7 @@ 'media_class': 'directory', 'media_content_id': 'media-source://media_source/local/.', 'media_content_type': '', - 'not_shown': 1, + 'not_shown': 2, 'search_media_classes': None, 'thumbnail': None, 'title': 'media', diff --git a/tests/components/hive/test_init.py b/tests/components/hive/test_init.py index e33a554f25b82..936052be72179 100644 --- a/tests/components/hive/test_init.py +++ b/tests/components/hive/test_init.py @@ -3,10 +3,11 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, patch +from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.hive.const import DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ATTR_DEVICE_CLASS, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry @@ -50,6 +51,23 @@ "status": {"state": True}, } +_GLASS_BREAK_BINARY_SENSOR = { + "device_id": "hive-glass-break-id", + "hiveID": "hive-glass-break-id", + "hiveName": "Glass Break", + "haName": "Glass Break", + "device_name": "Glass Break Sensor", + "hiveType": "GLASS_BREAK", + "parentDevice": "hive-hub-id", + "deviceData": { + "model": "Glass Break Sensor", + "version": "1.2.3", + "manufacturer": "Hive", + "online": True, + }, + "status": {"state": False}, +} + # The hub's own diagnostic sensor reports the hub as its own parent # (parentDevice == device_id), which would link the hub device to itself. _HUB_BINARY_SENSOR = { @@ -195,3 +213,34 @@ async def test_hub_diagnostic_sensor_not_linked_to_itself( ) assert hub_device is not None assert hub_device.via_device_id is None + + +async def test_glass_break_device_class( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test the glass break binary sensor device class.""" + entry = MockConfigEntry(domain=DOMAIN, data=_ENTRY_DATA) + entry.add_to_hass(hass) + + mock_hive = _make_mock_hive( + {"macAddress": "00:1C:2B:1C:2E:68"}, + {"binary_sensor": [_GLASS_BREAK_BINARY_SENSOR], "sensor": []}, + ) + mock_hive.session.updateData = AsyncMock() + mock_hive.sensor.getSensor = AsyncMock(side_effect=lambda device: device) + + with patch( + "homeassistant.components.hive.Hive", + return_value=mock_hive, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = entity_registry.async_get_entity_id( + "binary_sensor", DOMAIN, "hive-glass-break-id-GLASS_BREAK" + ) + assert entity_id + state = hass.states.get(entity_id) + assert state + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.GLASS_BREAK diff --git a/tests/components/homeassistant/test_llm.py b/tests/components/homeassistant/test_llm.py index a568d79d34d57..0d7fd73ed74fa 100644 --- a/tests/components/homeassistant/test_llm.py +++ b/tests/components/homeassistant/test_llm.py @@ -412,3 +412,45 @@ async def test_get_live_context_schema( schema = to_openapi(tool.parameters, custom_serializer=api.custom_serializer) assert schema == snapshot + + +async def test_get_exposed_entities_brightness_percentage(hass: HomeAssistant) -> None: + """Test that a light's brightness is also rendered as a percentage.""" + hass.states.async_set( + ENTITY_ID, "on", {"friendly_name": "Kitchen Light", "brightness": 128} + ) + async_expose_entity(hass, "conversation", ENTITY_ID, True) + + exposed = async_get_exposed_entities(hass, "conversation", include_state=True) + attributes = exposed[ENTITY_ID]["attributes"] + + # The raw attribute is unchanged, so anything reading it keeps working. + assert attributes["brightness"] == "128" + # The percentage is the inverse of the percentage-to-brightness conversion. + assert attributes["brightness_pct"] == "50" + + hass.states.async_set( + ENTITY_ID, "on", {"friendly_name": "Kitchen Light", "brightness": 255} + ) + exposed = async_get_exposed_entities(hass, "conversation", include_state=True) + assert exposed[ENTITY_ID]["attributes"]["brightness_pct"] == "100" + + # A lit light never rounds down to nothing. + hass.states.async_set( + ENTITY_ID, "on", {"friendly_name": "Kitchen Light", "brightness": 1} + ) + exposed = async_get_exposed_entities(hass, "conversation", include_state=True) + assert exposed[ENTITY_ID]["attributes"]["brightness_pct"] == "1" + + hass.states.async_set(ENTITY_ID, "off", {"friendly_name": "Kitchen Light"}) + exposed = async_get_exposed_entities(hass, "conversation", include_state=True) + assert "brightness_pct" not in exposed[ENTITY_ID].get("attributes", {}) + + # Only lights get the percentage, whatever attribute another domain carries. + hass.states.async_set( + "fan.kitchen", "on", {"friendly_name": "Kitchen Fan", "brightness": 128} + ) + async_expose_entity(hass, "conversation", "fan.kitchen", True) + exposed = async_get_exposed_entities(hass, "conversation", include_state=True) + assert exposed["fan.kitchen"]["attributes"]["brightness"] == "128" + assert "brightness_pct" not in exposed["fan.kitchen"]["attributes"] diff --git a/tests/components/homematicip_cloud/test_binary_sensor.py b/tests/components/homematicip_cloud/test_binary_sensor.py index ddf03a299dbde..5122063c6eeaf 100644 --- a/tests/components/homematicip_cloud/test_binary_sensor.py +++ b/tests/components/homematicip_cloud/test_binary_sensor.py @@ -10,6 +10,7 @@ ) import pytest +from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.homematicip_cloud.binary_sensor import ( ATTR_ACCELERATION_SENSOR_MODE, ATTR_ACCELERATION_SENSOR_NEUTRAL_POSITION, @@ -30,7 +31,7 @@ ATTR_RSSI_DEVICE, ATTR_SABOTAGE, ) -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN +from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -69,6 +70,9 @@ async def test_hmip_full_flush_lock_controller_binary_sensors( "HmIP-FLC", ) assert glass_state.state == STATE_ON + assert ( + glass_state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.GLASS_BREAK + ) assert hmip_device is not None await async_manipulate_test_data(hass, hmip_device, "lockState", "UNLOCKED") diff --git a/tests/components/icloud/test_account.py b/tests/components/icloud/test_account.py index 3ddc13b5d6992..7ba37caa66500 100644 --- a/tests/components/icloud/test_account.py +++ b/tests/components/icloud/test_account.py @@ -99,6 +99,11 @@ def __init__(self, userinfo, devices) -> None: """Initialize with userinfo and list of device objects.""" self.user_info = userinfo self._devices = devices + self.refresh_calls: list[bool] = [] + + def refresh(self, locate: bool = False) -> None: + """Record refresh calls made by the account.""" + self.refresh_calls.append(locate) def __iter__(self): """Iterate returns device objects (each must have .status(...)).""" @@ -165,3 +170,7 @@ async def test_setup_success_with_devices( assert account.owner_fullname == "user name" assert "johntravolta" in account.family_members_fullname assert account.family_members_fullname["johntravolta"] == "John TRAVOLTA" + # An active locate must be requested on every poll (pyicloud >= 2.3.0 + # only locates at service creation, so the account has to ask for it) + assert mock_icloud_service.devices.refresh_calls == [True] + assert "device1" in account.devices diff --git a/tests/components/kodi/test_notify.py b/tests/components/kodi/test_notify.py index fedd96be52dae..62110a5373471 100644 --- a/tests/components/kodi/test_notify.py +++ b/tests/components/kodi/test_notify.py @@ -5,6 +5,7 @@ import jsonrpc_async import pytest +from homeassistant.components.kodi.const import DOMAIN from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -47,7 +48,10 @@ async def test_send_message_transport_error(hass: HomeAssistant) -> None: ) await setup_notify(hass, server) - with pytest.raises(HomeAssistantError): + with pytest.raises(HomeAssistantError) as err: await hass.services.async_call( NOTIFY_DOMAIN, "kodi", {"message": "Hello"}, blocking=True ) + + assert err.value.translation_domain == DOMAIN + assert err.value.translation_key == "notify_failed" diff --git a/tests/components/matter/test_fan.py b/tests/components/matter/test_fan.py index 67e5f9614230d..ff471255e2f0d 100644 --- a/tests/components/matter/test_fan.py +++ b/tests/components/matter/test_fan.py @@ -504,3 +504,24 @@ async def test_fan_set_percentage_without_multispeed( attribute_path="1/514/2", value=75, ) + + +@pytest.mark.parametrize("node_fixture", ["mock_air_purifier"]) +@pytest.mark.parametrize("attributes", [{"1/514/4": 0}]) +async def test_fan_without_speed_steps( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test a fan reporting no speed steps keeps the default step size.""" + entity_id = "fan.mock_air_purifier" + state = hass.states.get(entity_id) + assert state + assert state.attributes["percentage_step"] == 1 + + # a proper speed count is picked up when the features are recalculated + set_node_attribute(matter_node, 1, 514, 4, 5) + set_node_attribute(matter_node, 1, 514, 65532, 47) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state.attributes["percentage_step"] == 20 diff --git a/tests/components/matter/test_light.py b/tests/components/matter/test_light.py index 4b67e8c7b8c74..0275cd53019f9 100644 --- a/tests/components/matter/test_light.py +++ b/tests/components/matter/test_light.py @@ -565,3 +565,36 @@ async def test_light_unexpected_color_mode( transitionTime=0, ), ) + + +@pytest.mark.parametrize("node_fixture", ["mock_dimmable_light"]) +async def test_dimmable_light_without_level_range( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test a light reporting a level range without width uses the default range.""" + entity_id = "light.mock_dimmable_light" + set_node_attribute(matter_node, 1, 8, 2, 5) + set_node_attribute(matter_node, 1, 8, 3, 5) + set_node_attribute(matter_node, 1, 8, 0, 5) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state + assert state.attributes["brightness"] == 4 + + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": entity_id, "brightness": 128}, + blocking=True, + ) + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.LevelControl.Commands.MoveToLevelWithOnOff( + level=128, + transitionTime=0, + ), + ) diff --git a/tests/components/media_source/test_helper.py b/tests/components/media_source/test_helper.py index 0f5a236ac6d44..fdbddc5aa94ca 100644 --- a/tests/components/media_source/test_helper.py +++ b/tests/components/media_source/test_helper.py @@ -25,7 +25,7 @@ async def test_async_browse_media(hass: HomeAssistant) -> None: media = await media_source.async_browse_media(hass, "") assert isinstance(media, media_source.models.BrowseMediaSource) assert media.title == "media" - assert len(media.children) == 2 + assert len(media.children) == 3 # Test content filter media = await media_source.async_browse_media( @@ -37,7 +37,7 @@ async def test_async_browse_media(hass: HomeAssistant) -> None: assert media.title == "media" assert len(media.children) == 1, media.children media.children[0].title = "Epic Sax Guy 10 Hours" - assert media.not_shown == 1 + assert media.not_shown == 2 # Test content filter adds to original not_shown orig_browse = models.MediaSourceItem.async_browse @@ -61,7 +61,7 @@ async def not_shown_browse(self): assert media.title == "media" assert len(media.children) == 1, media.children media.children[0].title = "Epic Sax Guy 10 Hours" - assert media.not_shown == 11 + assert media.not_shown == 12 # Test invalid media content with pytest.raises(BrowseError): diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index 6a365d9a17c15..90989e51aed43 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -314,7 +314,7 @@ async def test_function_call( {probatio.Optional("param1", description="Test parameters"): str}, extra=probatio.ALLOW_EXTRA, ) - mock_tool.async_call.return_value = "Test response" + mock_tool.async_call.return_value = llm.ToolResult(data="Test response") mock_get_tools.return_value = LLMTools(tools=[mock_tool]) diff --git a/tests/components/opentherm_gw/test_button.py b/tests/components/opentherm_gw/test_button.py index 71e453789a881..cd252c7a2b8fc 100644 --- a/tests/components/opentherm_gw/test_button.py +++ b/tests/components/opentherm_gw/test_button.py @@ -50,6 +50,40 @@ async def test_cancel_room_setpoint_override_button( mock_pyotgw.return_value.set_target_temp.assert_awaited_once_with(0, True) +async def test_hot_water_push_button( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_pyotgw: MagicMock, +) -> None: + """Test hot water push button.""" + + mock_pyotgw.return_value.set_hot_water_ovrd = AsyncMock(return_value="P") + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + button_entity_id := entity_registry.async_get_entity_id( + BUTTON_DOMAIN, + DOMAIN, + f"{mock_config_entry.data[CONF_ID]}-{OpenThermDeviceIdentifier.BOILER}-hot_water_push", + ) + ) is not None + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: button_entity_id, + }, + blocking=True, + ) + + mock_pyotgw.return_value.set_hot_water_ovrd.assert_awaited_once_with("P") + + async def test_restart_button( hass: HomeAssistant, entity_registry: er.EntityRegistry, diff --git a/tests/components/ourgroceries/test_todo.py b/tests/components/ourgroceries/test_todo.py index d364881b624a9..72d1c44c46bc5 100644 --- a/tests/components/ourgroceries/test_todo.py +++ b/tests/components/ourgroceries/test_todo.py @@ -217,7 +217,7 @@ async def test_remove_todo_item( assert state assert state.state == "2" - ourgroceries.remove_item_from_list = AsyncMock() + ourgroceries.edit_items = AsyncMock() # Fake API response when state is refreshed after remove _mock_version_id(ourgroceries, 2) ourgroceries.get_list_items.return_value = items_to_shopping_list([]) @@ -229,10 +229,13 @@ async def test_remove_todo_item( target={ATTR_ENTITY_ID: "todo.test_list"}, blocking=True, ) - assert ourgroceries.remove_item_from_list.call_count == 2 - args = ourgroceries.remove_item_from_list.call_args_list - assert args[0].args == ("test_list", "12345") - assert args[1].args == ("test_list", "54321") + ourgroceries.edit_items.assert_called_once_with( + "test_list", + [ + {"editType": "delete", "itemId": "12345"}, + {"editType": "delete", "itemId": "54321"}, + ], + ) await async_update_entity(hass, "todo.test_list") state = hass.states.get("todo.test_list") diff --git a/tests/components/ring/test_config_flow.py b/tests/components/ring/test_config_flow.py index f5763e0340e85..f038d440b0c20 100644 --- a/tests/components/ring/test_config_flow.py +++ b/tests/components/ring/test_config_flow.py @@ -7,6 +7,7 @@ from homeassistant import config_entries from homeassistant.components.ring import DOMAIN +from homeassistant.components.ring.const import CONF_LISTEN_CREDENTIALS from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -128,6 +129,14 @@ async def test_reauth( mock_ring_auth: Mock, ) -> None: """Test reauth flow.""" + listen_credentials = {"gcm": {"android_id": "stored-android-id"}} + hass.config_entries.async_update_entry( + mock_added_config_entry, + data={ + **mock_added_config_entry.data, + CONF_LISTEN_CREDENTIALS: listen_credentials, + }, + ) mock_added_config_entry.async_start_reauth(hass) await hass.async_block_till_done() @@ -165,6 +174,7 @@ async def test_reauth( CONF_DEVICE_ID: MOCK_HARDWARE_ID, CONF_USERNAME: "foo@bar.com", CONF_TOKEN: "new-foobar", + CONF_LISTEN_CREDENTIALS: listen_credentials, } assert len(mock_setup_entry.mock_calls) == 1 @@ -311,6 +321,15 @@ async def test_reconfigure( ) -> None: """Test the reconfigure config flow.""" + listen_credentials = {"gcm": {"android_id": "stored-android-id"}} + hass.config_entries.async_update_entry( + mock_added_config_entry, + data={ + **mock_added_config_entry.data, + CONF_LISTEN_CREDENTIALS: listen_credentials, + }, + ) + assert mock_added_config_entry.data[CONF_DEVICE_ID] == MOCK_HARDWARE_ID result = await mock_added_config_entry.start_reconfigure_flow(hass) @@ -328,6 +347,7 @@ async def test_reconfigure( assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "reconfigure_successful" assert mock_added_config_entry.data[CONF_DEVICE_ID] == "new-hardware-id" + assert mock_added_config_entry.data[CONF_LISTEN_CREDENTIALS] == listen_credentials @pytest.mark.parametrize( diff --git a/tests/components/roku/test_media_player.py b/tests/components/roku/test_media_player.py index 69d81de7656e6..55fdde1631685 100644 --- a/tests/components/roku/test_media_player.py +++ b/tests/components/roku/test_media_player.py @@ -956,7 +956,7 @@ async def test_media_browse_local_source( assert msg["result"]["title"] == "media" assert msg["result"]["media_class"] == MediaClass.DIRECTORY assert msg["result"]["media_content_type"] == "" - assert len(msg["result"]["children"]) == 2 + assert len(msg["result"]["children"]) == 3 assert msg["result"]["children"][0]["title"] == "Epic Sax Guy 10 Hours.mp4" assert msg["result"]["children"][0]["media_class"] == MediaClass.VIDEO diff --git a/tests/components/simplisafe/test_init.py b/tests/components/simplisafe/test_init.py index f80640d49ed03..acfe41aa24a8e 100644 --- a/tests/components/simplisafe/test_init.py +++ b/tests/components/simplisafe/test_init.py @@ -1,5 +1,6 @@ """Define tests for SimpliSafe setup.""" +import copy from unittest.mock import AsyncMock, Mock from freezegun.api import FrozenDateTimeFactory @@ -10,14 +11,19 @@ RequestError, SimplipyError, ) +from simplipy.system.v3 import SystemV3 from simplipy.websocket import WebsocketEvent +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) from homeassistant.components.simplisafe import DOMAIN from homeassistant.components.simplisafe.coordinator import DEFAULT_SCAN_INTERVAL from homeassistant.config_entries import SOURCE_REAUTH -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, async_fire_time_changed @@ -75,6 +81,36 @@ async def test_base_station_model_is_string( assert isinstance(device.model, str) +async def test_glass_break_device_class( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + patch_simplisafe_api: None, + system_v3: SystemV3, +) -> None: + """Test the glass break binary sensor device class.""" + system_v3.sensor_data = copy.deepcopy(system_v3.sensor_data) + system_v3.sensor_data["glass"] = { + "serial": "glass", + "type": 6, + "name": "Living Room", + "status": {"triggered": False}, + "flags": {"lowBattery": False, "offline": False}, + } + system_v3.generate_device_objects() + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = entity_registry.async_get_entity_id( + BINARY_SENSOR_DOMAIN, DOMAIN, "glass" + ) + assert entity_id + state = hass.states.get(entity_id) + assert state + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.GLASS_BREAK + + async def test_coordinator_update_triggers_reauth_on_invalid_credentials( hass: HomeAssistant, config_entry: MockConfigEntry, diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index 2cd53265f05ac..a628c53fa2581 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -10,7 +10,7 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant import setup +from homeassistant import config_entries, setup from homeassistant.components import binary_sensor, template from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -21,10 +21,12 @@ STATE_UNKNOWN, ) from homeassistant.core import Context, CoreState, HomeAssistant, State +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY from homeassistant.helpers.typing import ConfigType +from homeassistant.util import dt as dt_util from .conftest import ( RESTORE_STATE_SAVED_ATTRIBUTES, @@ -598,6 +600,143 @@ async def test_delay_off(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> assert hass.states.get(TEST_BINARY_SENSOR.entity_id).state == STATE_OFF +@pytest.mark.parametrize( + ("advanced_input", "expected_advanced_options", "expected_state"), + [ + ( + {"delay_on": {"seconds": 5}}, + {"delay_on": {"seconds": 5.0}}, + STATE_UNKNOWN, + ), + ( + {"delay_off": {"minutes": 1}}, + {"delay_off": {"minutes": 1.0}}, + STATE_ON, + ), + ( + {"delay_on": {"seconds": 5}, "delay_off": {"minutes": 1}}, + {"delay_on": {"seconds": 5.0}, "delay_off": {"minutes": 1.0}}, + STATE_UNKNOWN, + ), + ], +) +async def test_config_flow_binary_sensor_delay_options( + hass: HomeAssistant, + advanced_input: dict[str, Any], + expected_advanced_options: dict[str, Any], + expected_state: str, +) -> None: + """Test delay options in the binary sensor config flow.""" + result = await hass.config_entries.flow.async_init( + template.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "binary_sensor"}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "binary_sensor" + + with patch( + "homeassistant.components.template.async_setup_entry", + wraps=template.async_setup_entry, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "name": "My template", + "state": "{{ true }}", + "additional_options": advanced_input, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "My template" + assert result["data"] == {} + assert result["options"] == { + "name": "My template", + "template_type": "binary_sensor", + "state": "{{ true }}", + "additional_options": expected_advanced_options, + } + assert len(mock_setup_entry.mock_calls) == 1 + + config_entry = hass.config_entries.async_entries(template.DOMAIN)[0] + assert config_entry.data == {} + assert config_entry.options == { + "name": "My template", + "template_type": "binary_sensor", + "state": "{{ true }}", + "additional_options": expected_advanced_options, + } + state = hass.states.get("binary_sensor.my_template") + assert state.state == expected_state + + +async def test_config_flow_preview_binary_sensor_delay( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview with a delayed binary sensor.""" + client = await hass_ws_client(hass) + + hass.states.async_set("binary_sensor.available", "on") + hass.states.async_set("binary_sensor.one", "off") + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + template.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "binary_sensor"}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "binary_sensor" + assert result["preview"] == "template" + + await client.send_json_auto_id( + { + "type": "template/start_preview", + "flow_id": result["flow_id"], + "flow_type": "config_flow", + "user_input": { + "name": "My template", + "state": "{{ is_state('binary_sensor.one', 'on') }}", + "additional_options": { + "availability": "{{ True }}", + "delay_on": {"seconds": 1}, + }, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + msg = await client.receive_json() + assert msg["event"]["state"] == "off" + + hass.states.async_set("binary_sensor.one", "on") + await hass.async_block_till_done() + + msg = await client.receive_json() + assert msg["event"]["state"] == "off" + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) + await hass.async_block_till_done() + + msg = await client.receive_json() + assert msg["event"]["state"] == "on" + + @pytest.mark.parametrize( ("count", "state_template", "extra_config"), [ diff --git a/tests/components/tesla_fleet/test_init.py b/tests/components/tesla_fleet/test_init.py index e660cb767de18..991cc934a7bb9 100644 --- a/tests/components/tesla_fleet/test_init.py +++ b/tests/components/tesla_fleet/test_init.py @@ -944,6 +944,41 @@ async def test_energy_history_refresh_ratelimited( assert mock_energy_history.call_count == 2 +async def test_energy_history_refresh_ratelimited_not_sticky( + hass: HomeAssistant, + normal_config_entry: MockConfigEntry, + mock_energy_history: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a 429 backoff only delays the next energy history refresh.""" + + await setup_platform(hass, normal_config_entry) + + after = ENERGY_HISTORY_INTERVAL + timedelta(seconds=10) + # The library passes the raw Retry-After header through as a string + mock_energy_history.side_effect = RateLimited( + {"after": str(int(after.total_seconds()))} + ) + freezer.tick(ENERGY_HISTORY_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_energy_history.call_count == 1 + + mock_energy_history.side_effect = None + freezer.tick(after) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_energy_history.call_count == 2 + + freezer.tick(ENERGY_HISTORY_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_energy_history.call_count == 3 + + async def test_init_region_issue( hass: HomeAssistant, normal_config_entry: MockConfigEntry, diff --git a/tests/components/unifi/test_hub.py b/tests/components/unifi/test_hub.py index ab3821fdb8512..d1242cf03870a 100644 --- a/tests/components/unifi/test_hub.py +++ b/tests/components/unifi/test_hub.py @@ -6,6 +6,7 @@ from unittest.mock import patch import aiounifi +from aiounifi import EndpointNotFound from aiounifi.interfaces.api_handlers import ItemEvent from aiounifi.models.message import MessageKey import pytest @@ -122,6 +123,49 @@ async def test_polling_coordinator_refreshes_after_interval( assert mock_update.call_count >= 1 +async def test_endpoint_not_found_disables_object_oriented_network_config_polling( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + config_entry_setup: MockConfigEntry, +) -> None: + """Ensure an unavailable optional endpoint stops polling after one warning.""" + loader = config_entry_setup.runtime_data.entity_loader + api = config_entry_setup.runtime_data.api + coordinator = loader.get_data_update_coordinator( + api.object_oriented_network_configs + ) + traffic_rules_coordinator = loader.get_data_update_coordinator(api.traffic_rules) + + with patch.object( + coordinator.handler, + "update", + side_effect=EndpointNotFound("endpoint not found"), + ) as mock_update: + await coordinator.async_refresh() + await hass.async_block_till_done() + + assert coordinator.update_interval is None + assert coordinator.last_update_success is False + assert mock_update.call_count == 1 + + async_fire_time_changed(hass, dt_util.utcnow() + POLL_INTERVAL) + await hass.async_block_till_done() + + assert mock_update.call_count == 1 + + await coordinator.async_refresh() + await hass.async_block_till_done() + + assert mock_update.call_count == 2 + assert traffic_rules_coordinator.update_interval == POLL_INTERVAL + assert ( + caplog.text.count( + "UniFi ObjectOrientedNetworkConfigs endpoint is unavailable; disabling polling" + ) + == 1 + ) + + @pytest.mark.parametrize( "object_oriented_network_config_payload", [ diff --git a/tests/components/unifiprotect/test_relay.py b/tests/components/unifiprotect/test_relay.py index b4107c90e6072..4d6c42c1a9bfe 100644 --- a/tests/components/unifiprotect/test_relay.py +++ b/tests/components/unifiprotect/test_relay.py @@ -6,6 +6,7 @@ import pytest from uiprotect.data import ( + DeviceState, ModelType, PublicRelayOutput, Relay, @@ -62,6 +63,7 @@ def _make_output( def _make_relay( *, outputs: list[Mock] | None = None, + state: DeviceState = DeviceState.CONNECTED, ) -> Mock: """Build a mock :class:`Relay` whose ``activate_output`` is awaitable.""" relay = Mock(spec=Relay) @@ -69,6 +71,7 @@ def _make_relay( relay.mac = RELAY_MAC relay.name = RELAY_NAME relay.model = ModelType.RELAY + relay.state = state relay.outputs = outputs if outputs is not None else [_make_output()] def get_output(output_id: int) -> Mock | None: @@ -389,6 +392,47 @@ async def test_relay_switch_becomes_unavailable_when_relay_removed( assert state.state == STATE_UNAVAILABLE +@pytest.mark.parametrize( + "state", + [DeviceState.DISCONNECTED, DeviceState.CONNECTING, DeviceState.UNKNOWN], +) +async def test_relay_switch_unavailable_when_not_connected_at_setup( + hass: HomeAssistant, + ufp: MockUFPFixture, + state: DeviceState, +) -> None: + """A relay that is not connected at setup starts out unavailable.""" + ufp.api.has_public_bootstrap = True + ufp.api.public_bootstrap = _make_public_bootstrap(_make_relay(state=state)) + + await init_entry(hass, ufp, []) + + assert hass.states.get(SWITCH_ENTITY_ID).state == STATE_UNAVAILABLE + + +async def test_relay_switch_unavailable_when_disconnected( + hass: HomeAssistant, + ufp_with_relay: tuple[MockUFPFixture, Mock], +) -> None: + """A relay that drops off the console is unavailable, and recovers.""" + ufp, relay = ufp_with_relay + relay.outputs[0].state = RelayOutputState.ON + await init_entry(hass, ufp, []) + assert hass.states.get(SWITCH_ENTITY_ID).state == STATE_ON + + relay.state = DeviceState.DISCONNECTED + ufp.devices_ws_subscription(public_device_ws_message(relay)) + await hass.async_block_till_done() + + assert hass.states.get(SWITCH_ENTITY_ID).state == STATE_UNAVAILABLE + + relay.state = DeviceState.CONNECTED + ufp.devices_ws_subscription(public_device_ws_message(relay)) + await hass.async_block_till_done() + + assert hass.states.get(SWITCH_ENTITY_ID).state == STATE_ON + + async def test_relay_switch_availability_follows_websocket_state( hass: HomeAssistant, ufp_with_relay: tuple[MockUFPFixture, Mock], diff --git a/tests/components/unifiprotect/test_siren.py b/tests/components/unifiprotect/test_siren.py index dc16ef132eef0..55c8b9e5af0ca 100644 --- a/tests/components/unifiprotect/test_siren.py +++ b/tests/components/unifiprotect/test_siren.py @@ -4,7 +4,14 @@ from unittest.mock import AsyncMock, Mock import pytest -from uiprotect.data import ModelType, PublicSirenStatus, Siren, SirenDuration, WSAction +from uiprotect.data import ( + DeviceState, + ModelType, + PublicSirenStatus, + Siren, + SirenDuration, + WSAction, +) from uiprotect.exceptions import ClientError, NotAuthorized from uiprotect.websocket import WebsocketState @@ -44,7 +51,9 @@ SIREN_ENTITY_ID = "siren.garage_siren" -def _make_siren(*, is_active: bool = False) -> Mock: +def _make_siren( + *, is_active: bool = False, state: DeviceState = DeviceState.CONNECTED +) -> Mock: """Build a mock :class:`Siren`.""" status = Mock(spec=PublicSirenStatus) status.is_active = is_active @@ -56,6 +65,7 @@ def _make_siren(*, is_active: bool = False) -> Mock: siren.mac = SIREN_MAC siren.name = SIREN_NAME siren.model = ModelType.SIREN + siren.state = state siren.volume = 50 siren.siren_status = status siren.is_active = is_active @@ -599,6 +609,46 @@ async def test_siren_auto_off_when_already_expired_at_update( assert state.state == STATE_OFF +@pytest.mark.parametrize( + "state", + [DeviceState.DISCONNECTED, DeviceState.CONNECTING, DeviceState.UNKNOWN], +) +async def test_siren_unavailable_when_not_connected_at_setup( + hass: HomeAssistant, + ufp: MockUFPFixture, + state: DeviceState, +) -> None: + """A siren that is not connected at setup starts out unavailable.""" + ufp.api.has_public_bootstrap = True + ufp.api.public_bootstrap = _make_public_bootstrap(_make_siren(state=state)) + + await init_entry(hass, ufp, []) + + assert hass.states.get(SIREN_ENTITY_ID).state == STATE_UNAVAILABLE + + +async def test_siren_unavailable_when_disconnected( + hass: HomeAssistant, + ufp_with_siren: MockUFPFixture, + siren: Mock, +) -> None: + """A siren that drops off the console is unavailable, and recovers.""" + await init_entry(hass, ufp_with_siren, []) + assert hass.states.get(SIREN_ENTITY_ID).state == STATE_OFF + + siren.state = DeviceState.DISCONNECTED + ufp_with_siren.devices_ws_subscription(_make_ws_msg(siren)) + await hass.async_block_till_done() + + assert hass.states.get(SIREN_ENTITY_ID).state == STATE_UNAVAILABLE + + siren.state = DeviceState.CONNECTED + ufp_with_siren.devices_ws_subscription(_make_ws_msg(siren)) + await hass.async_block_till_done() + + assert hass.states.get(SIREN_ENTITY_ID).state == STATE_OFF + + async def test_siren_unavailable_on_delete_event( hass: HomeAssistant, ufp_with_siren: MockUFPFixture, diff --git a/tests/components/vicare/snapshots/test_sensor.ambr b/tests/components/vicare/snapshots/test_sensor.ambr index 4fb6020f4eb80..e18404e5361da 100644 --- a/tests/components/vicare/snapshots/test_sensor.ambr +++ b/tests/components/vicare/snapshots/test_sensor.ambr @@ -6063,6 +6063,7 @@ 'sensordriven', 'silent', 'forcedlevelfour', + 'sensoroverride', ]), }), 'config_entry_id': , @@ -6107,6 +6108,7 @@ 'sensordriven', 'silent', 'forcedlevelfour', + 'sensoroverride', ]), }), 'context': , @@ -6197,6 +6199,7 @@ 'sensordriven', 'silent', 'forcedlevelfour', + 'sensoroverride', ]), }), 'config_entry_id': , @@ -6241,6 +6244,7 @@ 'sensordriven', 'silent', 'forcedlevelfour', + 'sensoroverride', ]), }), 'context': , @@ -6989,6 +6993,7 @@ 'sensordriven', 'silent', 'forcedlevelfour', + 'sensoroverride', ]), }), 'config_entry_id': , @@ -7033,6 +7038,7 @@ 'sensordriven', 'silent', 'forcedlevelfour', + 'sensoroverride', ]), }), 'context': , diff --git a/tests/components/zwave_js/test_binary_sensor.py b/tests/components/zwave_js/test_binary_sensor.py index c6d942577cb3c..26027acf235df 100644 --- a/tests/components/zwave_js/test_binary_sensor.py +++ b/tests/components/zwave_js/test_binary_sensor.py @@ -152,6 +152,25 @@ def _add_lock_state_notification_states(node_state: dict[str, Any]) -> dict[str, return updated_state +def _add_glass_break_notification_states( + node_state: dict[str, Any], +) -> dict[str, Any]: + """Return a node state with Home Security glass break notification states.""" + updated_state = copy.deepcopy(node_state) + for value_data in updated_state["values"]: + if ( + value_data.get("commandClass") == 113 + and value_data.get("property") == "Home Security" + ): + value_data["metadata"]["states"] = { + "0": "idle", + "5": "Glass breakage detected (location provided)", + "6": "Glass breakage detected", + } + break + return updated_state + + def _set_opening_state_metadata_states( node_state: dict[str, Any], states: dict[str, str] ) -> dict[str, Any]: @@ -330,6 +349,33 @@ async def test_notification_sensor( assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC +async def test_glass_break_notification_sensor( + hass: HomeAssistant, + client: MagicMock, + lock_schlage_be469_state: NodeDataType, +) -> None: + """Test the glass break notification sensor device class.""" + node = Node( + client, + _add_glass_break_notification_states(lock_schlage_be469_state), + ) + client.driver.controller.nodes[node.node_id] = node + + entry = MockConfigEntry(domain=DOMAIN, data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + glass_break_states = [ + state + for state in hass.states.async_all(BINARY_SENSOR_DOMAIN) + if state.attributes.get(ATTR_DEVICE_CLASS) + == BinarySensorDeviceClass.GLASS_BREAK + ] + assert len(glass_break_states) == 2 + assert all(state.state == STATE_OFF for state in glass_break_states) + + @pytest.mark.parametrize( ("entity_id", "device_class"), [ diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 1872a294ddb2e..3539f34efc129 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -25,7 +25,7 @@ from homeassistant.setup import async_setup_component from homeassistant.util.json import JsonObjectType -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, MockModule, mock_integration @pytest.fixture(autouse=True) @@ -150,7 +150,9 @@ async def test_call_non_intent_tool_preserves_blank_arguments( tool_args = {"name": "", "response": " ", "other": None} tool = MagicMock(spec=llm.Tool) tool.name = "test_tool" - tool.async_call = AsyncMock(return_value={"tool_args": tool_args}) + tool.async_call = AsyncMock( + return_value=llm.ToolResult(data={"tool_args": tool_args}) + ) instance = llm.APIInstance( MyAPI(hass=hass, id="test", name="Test"), "", llm_context, [tool] ) @@ -161,31 +163,14 @@ async def test_call_non_intent_tool_preserves_blank_arguments( assert tool.async_call.await_args.args[1].tool_args is tool_args -@pytest.mark.parametrize( - ("tool_return_value", "expected"), - [ - pytest.param( - {"answer": 42}, - llm.ToolResult(data={"answer": 42}), - id="plain-json-object", - ), - pytest.param( - llm.ToolResult(data={"answer": 42}, error=True), - llm.ToolResult(data={"answer": 42}, error=True), - id="tool-result", - ), - ], -) async def test_call_tool_result( - hass: HomeAssistant, - llm_context: llm.LLMContext, - tool_return_value: llm.ToolResult | JsonObjectType, - expected: llm.ToolResult, + hass: HomeAssistant, llm_context: llm.LLMContext ) -> None: - """Test a tool result is returned as is and a JSON object is wrapped.""" + """Test a tool result is returned as is.""" + expected = llm.ToolResult(data={"answer": 42}, error=True) tool = MagicMock(spec=llm.Tool) tool.name = "test_tool" - tool.async_call = AsyncMock(return_value=tool_return_value) + tool.async_call = AsyncMock(return_value=expected) instance = llm.APIInstance( MyAPI(hass=hass, id="test", name="Test"), "", llm_context, [tool] ) @@ -193,6 +178,58 @@ async def test_call_tool_result( assert await instance.async_call_tool(llm.ToolInput(tool.name, {})) == expected +@pytest.mark.usefixtures("mock_integration_frame") +async def test_call_tool_deprecated_json_object( + hass: HomeAssistant, llm_context: llm.LLMContext +) -> None: + """Test returning a JSON object from a tool is reported.""" + tool = MagicMock(spec=llm.Tool) + tool.name = "test_tool" + tool.async_call = AsyncMock(return_value={"answer": 42}) + instance = llm.APIInstance( + MyAPI(hass=hass, id="test", name="Test"), "", llm_context, [tool] + ) + + with pytest.raises(RuntimeError, match="returns a JSON object from a tool"): + await instance.async_call_tool(llm.ToolInput(tool.name, {})) + + +async def test_call_tool_deprecated_json_object_custom_integration( + hass: HomeAssistant, + llm_context: llm.LLMContext, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a custom integration tool returning a JSON object is logged, not raised.""" + mock_integration(hass, MockModule("my_custom"), built_in=False) + + class CustomTool(llm.Tool): + """Tool provided by a custom integration.""" + + name = "test_tool" + + async def async_call( + self, + hass: HomeAssistant, + tool_input: llm.ToolInput, + llm_context: llm.LLMContext, + ) -> JsonObjectType: + """Return a plain JSON object.""" + return {"answer": 42} + + # The tool call has returned by the time it is reported, so the domain is + # taken from the tool rather than the stack. + CustomTool.__module__ = "custom_components.my_custom.llm" + tool = CustomTool() + instance = llm.APIInstance( + MyAPI(hass=hass, id="test", name="Test"), "", llm_context, [tool] + ) + + assert await instance.async_call_tool( + llm.ToolInput(tool.name, {}) + ) == llm.ToolResult(data={"answer": 42}) + assert "returns a JSON object from a tool" in caplog.text + + @pytest.mark.parametrize("namespaced", [False, True]) async def test_intent_tool_omits_blank_arguments( hass: HomeAssistant, llm_context: llm.LLMContext, namespaced: bool @@ -783,8 +820,8 @@ def create_entity( ) dynamic_context_prompt = ( "You ARE equipped to answer questions about the" - " current state of\nthe home using the" - " `homeassistant__GetLiveContext` tool. This is a primary" + " current state of\nthe home by retrieving" + " live context. This is a primary" " function. Do not state you lack the\n" "functionality if the question requires live" " data.\nIf the user asks about device" @@ -796,7 +833,7 @@ def create_entity( ' "What mode is the thermostat in?", "What' ' is the temperature outside?"):\n' " 1. Recognize this requires live data.\n" - " 2. You MUST call `homeassistant__GetLiveContext`. This" + " 2. You MUST use the provided tool to retrieve live context. This" " tool will provide the needed real-time" " information (like temperature from the local" " weather, lock status, etc.).\n" @@ -1381,8 +1418,10 @@ def __init__(self, name: str, description: str) -> None: async def async_call( self, hass: HomeAssistant, tool_input: llm.ToolInput, _: llm.LLMContext - ) -> JsonObjectType: - return {"result": {tool_input.tool_name: tool_input.tool_args}} + ) -> llm.ToolResult: + return llm.ToolResult( + data={"result": {tool_input.tool_name: tool_input.tool_args}} + ) api1 = MyAPI(hass=hass, id="api-1", name="API 1") api1.prompt = "This is prompt 1" diff --git a/tests/testing_config/media/screenshot.jpg b/tests/testing_config/media/screenshot.jpg new file mode 100644 index 0000000000000..4587b284ac3d6 --- /dev/null +++ b/tests/testing_config/media/screenshot.jpg @@ -0,0 +1 @@ +ÿØÿÛ