diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 3f8dea3b657..e7dc7ebb270 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -131,7 +131,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2021.11.4 + uses: home-assistant/builder@2021.12.0 with: args: | $BUILD_ARGS \ @@ -170,6 +170,17 @@ jobs: - name: Checkout the repository uses: actions/checkout@v2.4.0 + - name: Set build additional args + run: | + # Create general tags + if [[ "${{ needs.init.outputs.version }}" =~ d ]]; then + echo "BUILD_ARGS=--additional-tag dev" >> $GITHUB_ENV + elif [[ "${{ needs.init.outputs.version }}" =~ b ]]; then + echo "BUILD_ARGS=--additional-tag beta" >> $GITHUB_ENV + else + echo "BUILD_ARGS=--additional-tag stable" >> $GITHUB_ENV + fi + - name: Login to DockerHub uses: docker/login-action@v1.10.0 with: @@ -184,7 +195,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2021.11.4 + uses: home-assistant/builder@2021.12.0 with: args: | $BUILD_ARGS \ diff --git a/homeassistant/components/brunt/manifest.json b/homeassistant/components/brunt/manifest.json index 976b017ca09..1ddbbb62f56 100644 --- a/homeassistant/components/brunt/manifest.json +++ b/homeassistant/components/brunt/manifest.json @@ -3,7 +3,7 @@ "name": "Brunt Blind Engine", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/brunt", - "requirements": ["brunt==1.0.0"], + "requirements": ["brunt==1.0.1"], "codeowners": ["@eavanvalkenburg"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index 3f3c31b8d3d..bee18948a33 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -3,7 +3,7 @@ "name": "Google Cast", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/cast", - "requirements": ["pychromecast==10.1.1"], + "requirements": ["pychromecast==10.2.1"], "after_dependencies": [ "cloud", "http", diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index 46c25501f3a..61922a4cd8b 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -47,7 +47,6 @@ from homeassistant.components.plex.const import PLEX_URI_SCHEME from homeassistant.components.plex.services import lookup_plex_media from homeassistant.const import ( CAST_APP_ID_HOMEASSISTANT_LOVELACE, - CAST_APP_ID_HOMEASSISTANT_MEDIA, EVENT_HOMEASSISTANT_STOP, STATE_IDLE, STATE_OFF, @@ -230,7 +229,6 @@ class CastDevice(MediaPlayerEntity): self._cast_info.cast_info, ChromeCastZeroconf.get_zeroconf(), ) - chromecast.media_controller.app_id = CAST_APP_ID_HOMEASSISTANT_MEDIA self._chromecast = chromecast if CAST_MULTIZONE_MANAGER_KEY not in self.hass.data: @@ -527,9 +525,8 @@ class CastDevice(MediaPlayerEntity): self._chromecast.register_handler(controller) controller.play_media(media) else: - self._chromecast.media_controller.play_media( - media_id, media_type, **kwargs.get(ATTR_MEDIA_EXTRA, {}) - ) + app_data = {"media_id": media_id, "media_type": media_type, **extra} + quick_play(self._chromecast, "homeassistant_media", app_data) def _media_status(self): """ @@ -820,7 +817,6 @@ class DynamicCastGroup: self._cast_info.cast_info, ChromeCastZeroconf.get_zeroconf(), ) - chromecast.media_controller.app_id = CAST_APP_ID_HOMEASSISTANT_MEDIA self._chromecast = chromecast if CAST_MULTIZONE_MANAGER_KEY not in self.hass.data: diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index b340674b480..868e62f07c3 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -2,7 +2,7 @@ "domain": "environment_canada", "name": "Environment Canada", "documentation": "https://www.home-assistant.io/integrations/environment_canada", - "requirements": ["env_canada==0.5.18"], + "requirements": ["env_canada==0.5.20"], "codeowners": ["@gwww", "@michaeldavie"], "config_flow": true, "iot_class": "cloud_polling" diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 4380440408f..4ac95c38afc 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20211212.0" + "home-assistant-frontend==20211215.0" ], "dependencies": [ "api", diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index ee337cd3d71..f32d8edc284 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -3,7 +3,7 @@ "name": "Philips Hue", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/hue", - "requirements": ["aiohue==3.0.3"], + "requirements": ["aiohue==3.0.5"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", diff --git a/homeassistant/components/hue/v2/device_trigger.py b/homeassistant/components/hue/v2/device_trigger.py index 74863a1897e..3f474cdf70b 100644 --- a/homeassistant/components/hue/v2/device_trigger.py +++ b/homeassistant/components/hue/v2/device_trigger.py @@ -7,6 +7,7 @@ from aiohue.v2.models.button import ButtonEvent from aiohue.v2.models.resource import ResourceTypes import voluptuous as vol +from homeassistant.components import persistent_notification from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.homeassistant.triggers import event as event_trigger from homeassistant.const import ( @@ -35,7 +36,7 @@ if TYPE_CHECKING: TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): str, - vol.Required(CONF_SUBTYPE): int, + vol.Required(CONF_SUBTYPE): vol.Union(int, str), vol.Optional(CONF_UNIQUE_ID): str, } ) @@ -54,6 +55,33 @@ DEVICE_SPECIFIC_EVENT_TYPES = { } +def check_invalid_device_trigger( + bridge: HueBridge, + config: ConfigType, + device_entry: DeviceEntry, + automation_info: AutomationTriggerInfo | None = None, +): + """Check automation config for deprecated format.""" + # NOTE: Remove this check after 2022.6 + if isinstance(config["subtype"], int): + return + # found deprecated V1 style trigger, notify the user that it should be adjusted + msg = ( + f"Incompatible device trigger detected for " + f"[{device_entry.name}](/config/devices/device/{device_entry.id}) " + "Please manually fix the outdated automation(s) once to fix this issue." + ) + if automation_info: + automation_id = automation_info["variables"]["this"]["attributes"]["id"] # type: ignore + msg += f"\n\n[Check it out](/config/automation/edit/{automation_id})." + persistent_notification.async_create( + bridge.hass, + msg, + title="Outdated device trigger found", + notification_id=f"hue_trigger_{device_entry.id}", + ) + + async def async_validate_trigger_config( bridge: "HueBridge", device_entry: DeviceEntry, @@ -61,6 +89,7 @@ async def async_validate_trigger_config( ): """Validate config.""" config = TRIGGER_SCHEMA(config) + check_invalid_device_trigger(bridge, config, device_entry) return config @@ -84,6 +113,7 @@ async def async_attach_trigger( }, } ) + check_invalid_device_trigger(bridge, config, device_entry, automation_info) return await event_trigger.async_attach_trigger( hass, event_config, action, automation_info, platform_type="device" ) diff --git a/homeassistant/components/hue/v2/entity.py b/homeassistant/components/hue/v2/entity.py index 68c427fd3a5..ae345238c23 100644 --- a/homeassistant/components/hue/v2/entity.py +++ b/homeassistant/components/hue/v2/entity.py @@ -47,6 +47,20 @@ class HueBaseEntity(Entity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.device.id)}, ) + # some (3th party) Hue lights report their connection status incorrectly + # causing the zigbee availability to report as disconnected while in fact + # it can be controlled. Although this is in fact something the device manufacturer + # should fix, we work around it here. If the light is reported unavailable at + # startup, we ignore the availability status of the zigbee connection + self._ignore_availability = False + if self.device is None: + return + if zigbee := self.bridge.api.devices.get_zigbee_connectivity(self.device.id): + self._ignore_availability = ( + # Official Hue lights are reliable + self.device.product_data.manufacturer_name != "Signify Netherlands B.V." + and zigbee.status != ConnectivityServiceStatus.CONNECTED + ) @property def name(self) -> str: @@ -98,13 +112,12 @@ class HueBaseEntity(Entity): def available(self) -> bool: """Return entity availability.""" if self.device is None: - # devices without a device attached should be always available + # entities without a device attached should be always available return True if self.resource.type == ResourceTypes.ZIGBEE_CONNECTIVITY: # the zigbee connectivity sensor itself should be always available return True - if self.device.product_data.manufacturer_name != "Signify Netherlands B.V.": - # availability status for non-philips brand lights is unreliable + if self._ignore_availability: return True if zigbee := self.bridge.api.devices.get_zigbee_connectivity(self.device.id): # all device-attached entities get availability from the zigbee connectivity diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py index 08f1dc72325..c5f7ae5d926 100644 --- a/homeassistant/components/hue/v2/group.py +++ b/homeassistant/components/hue/v2/group.py @@ -6,16 +6,19 @@ from typing import Any from aiohue.v2 import HueBridgeV2 from aiohue.v2.controllers.events import EventType from aiohue.v2.controllers.groups import GroupedLight, Room, Zone +from aiohue.v2.models.feature import AlertEffectType from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, + ATTR_FLASH, ATTR_TRANSITION, ATTR_XY_COLOR, COLOR_MODE_BRIGHTNESS, COLOR_MODE_COLOR_TEMP, COLOR_MODE_ONOFF, COLOR_MODE_XY, + SUPPORT_FLASH, SUPPORT_TRANSITION, LightEntity, ) @@ -32,6 +35,7 @@ ALLOWED_ERRORS = [ 'device (groupedLight) is "soft off", command (on) may not have effect', "device (light) has communication issues, command (on) may not have effect", 'device (light) is "soft off", command (on) may not have effect', + "attribute (supportedAlertActions) cannot be written", ] @@ -88,6 +92,7 @@ class GroupedHueLight(HueBaseEntity, LightEntity): self.group = group self.controller = controller self.api: HueBridgeV2 = bridge.api + self._attr_supported_features |= SUPPORT_FLASH self._attr_supported_features |= SUPPORT_TRANSITION # Entities for Hue groups are disabled by default @@ -146,6 +151,7 @@ class GroupedHueLight(HueBaseEntity, LightEntity): xy_color = kwargs.get(ATTR_XY_COLOR) color_temp = kwargs.get(ATTR_COLOR_TEMP) brightness = kwargs.get(ATTR_BRIGHTNESS) + flash = kwargs.get(ATTR_FLASH) if brightness is not None: # Hue uses a range of [0, 100] to control brightness. brightness = float((brightness / 255) * 100) @@ -160,6 +166,7 @@ class GroupedHueLight(HueBaseEntity, LightEntity): and xy_color is None and color_temp is None and transition is None + and flash is None ): await self.bridge.async_request_call( self.controller.set_state, @@ -180,17 +187,37 @@ class GroupedHueLight(HueBaseEntity, LightEntity): color_xy=xy_color if light.supports_color else None, color_temp=color_temp if light.supports_color_temperature else None, transition_time=transition, + alert=AlertEffectType.BREATHE if flash is not None else None, allowed_errors=ALLOWED_ERRORS, ) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" - await self.bridge.async_request_call( - self.controller.set_state, - id=self.resource.id, - on=False, - allowed_errors=ALLOWED_ERRORS, - ) + transition = kwargs.get(ATTR_TRANSITION) + if transition is not None: + # hue transition duration is in milliseconds + transition = int(transition * 1000) + + # NOTE: a grouped_light can only handle turn on/off + # To set other features, you'll have to control the attached lights + if transition is None: + await self.bridge.async_request_call( + self.controller.set_state, + id=self.resource.id, + on=False, + allowed_errors=ALLOWED_ERRORS, + ) + return + + # redirect all other feature commands to underlying lights + for light in self.controller.get_lights(self.resource.id): + await self.bridge.async_request_call( + self.api.lights.set_state, + light.id, + on=False, + transition_time=transition, + allowed_errors=ALLOWED_ERRORS, + ) @callback def on_update(self) -> None: diff --git a/homeassistant/components/hue/v2/light.py b/homeassistant/components/hue/v2/light.py index de5388e1220..afb4c3d88bd 100644 --- a/homeassistant/components/hue/v2/light.py +++ b/homeassistant/components/hue/v2/light.py @@ -6,17 +6,20 @@ from typing import Any from aiohue import HueBridgeV2 from aiohue.v2.controllers.events import EventType from aiohue.v2.controllers.lights import LightsController +from aiohue.v2.models.feature import AlertEffectType from aiohue.v2.models.light import Light from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, + ATTR_FLASH, ATTR_TRANSITION, ATTR_XY_COLOR, COLOR_MODE_BRIGHTNESS, COLOR_MODE_COLOR_TEMP, COLOR_MODE_ONOFF, COLOR_MODE_XY, + SUPPORT_FLASH, SUPPORT_TRANSITION, LightEntity, ) @@ -31,6 +34,7 @@ from .entity import HueBaseEntity ALLOWED_ERRORS = [ "device (light) has communication issues, command (on) may not have effect", 'device (light) is "soft off", command (on) may not have effect', + "attribute (supportedAlertActions) cannot be written", ] @@ -68,6 +72,7 @@ class HueLight(HueBaseEntity, LightEntity): ) -> None: """Initialize the light.""" super().__init__(bridge, controller, resource) + self._attr_supported_features |= SUPPORT_FLASH self.resource = resource self.controller = controller self._supported_color_modes = set() @@ -154,6 +159,7 @@ class HueLight(HueBaseEntity, LightEntity): xy_color = kwargs.get(ATTR_XY_COLOR) color_temp = kwargs.get(ATTR_COLOR_TEMP) brightness = kwargs.get(ATTR_BRIGHTNESS) + flash = kwargs.get(ATTR_FLASH) if brightness is not None: # Hue uses a range of [0, 100] to control brightness. brightness = float((brightness / 255) * 100) @@ -169,12 +175,14 @@ class HueLight(HueBaseEntity, LightEntity): color_xy=xy_color, color_temp=color_temp, transition_time=transition, + alert=AlertEffectType.BREATHE if flash is not None else None, allowed_errors=ALLOWED_ERRORS, ) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" transition = kwargs.get(ATTR_TRANSITION) + flash = kwargs.get(ATTR_FLASH) if transition is not None: # hue transition duration is in milliseconds transition = int(transition * 1000) @@ -183,5 +191,6 @@ class HueLight(HueBaseEntity, LightEntity): id=self.resource.id, on=False, transition_time=transition, + alert=AlertEffectType.BREATHE if flash is not None else None, allowed_errors=ALLOWED_ERRORS, ) diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index ba6689a023d..5a66824fbcb 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -383,6 +383,7 @@ class KNXModule: if _conn_type == CONF_KNX_ROUTING: return ConnectionConfig( connection_type=ConnectionType.ROUTING, + local_ip=self.config.get(ConnectionSchema.CONF_KNX_LOCAL_IP), auto_reconnect=True, ) if _conn_type == CONF_KNX_TUNNELING: diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py index 30071752731..96aa8f67e3b 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -137,9 +137,11 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): vol.Required( ConnectionSchema.CONF_KNX_ROUTE_BACK, default=False ): vol.Coerce(bool), - vol.Optional(ConnectionSchema.CONF_KNX_LOCAL_IP): str, } + if self.show_advanced_options: + fields[vol.Optional(ConnectionSchema.CONF_KNX_LOCAL_IP)] = str + return self.async_show_form( step_id="manual_tunnel", data_schema=vol.Schema(fields), errors=errors ) @@ -195,6 +197,9 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): CONF_KNX_INDIVIDUAL_ADDRESS: user_input[ CONF_KNX_INDIVIDUAL_ADDRESS ], + ConnectionSchema.CONF_KNX_LOCAL_IP: user_input.get( + ConnectionSchema.CONF_KNX_LOCAL_IP + ), CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, }, ) @@ -211,6 +216,9 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ): cv.port, } + if self.show_advanced_options: + fields[vol.Optional(ConnectionSchema.CONF_KNX_LOCAL_IP)] = str + return self.async_show_form( step_id="routing", data_schema=vol.Schema(fields), errors=errors ) @@ -306,7 +314,6 @@ class KNXOptionsFlowHandler(OptionsFlow): vol.Required( CONF_PORT, default=self.current_config.get(CONF_PORT, 3671) ): cv.port, - vol.Optional(ConnectionSchema.CONF_KNX_LOCAL_IP): str, vol.Required( ConnectionSchema.CONF_KNX_ROUTE_BACK, default=self.current_config.get( @@ -381,6 +388,14 @@ class KNXOptionsFlowHandler(OptionsFlow): } if self.show_advanced_options: + data_schema[ + vol.Optional( + ConnectionSchema.CONF_KNX_LOCAL_IP, + default=self.current_config.get( + ConnectionSchema.CONF_KNX_LOCAL_IP, + ), + ) + ] = str data_schema[ vol.Required( ConnectionSchema.CONF_KNX_STATE_UPDATER, diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index 7f770c25427..4db92888aab 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -28,7 +28,8 @@ "data": { "individual_address": "Individual address for the routing connection", "multicast_group": "The multicast group used for routing", - "multicast_port": "The multicast port used for routing" + "multicast_port": "The multicast port used for routing", + "local_ip": "Local IP (leave empty if unsure)" } } }, @@ -48,6 +49,7 @@ "individual_address": "Default individual address", "multicast_group": "Multicast group used for routing and discovery", "multicast_port": "Multicast port used for routing and discovery", + "local_ip": "Local IP (leave empty if unsure)", "state_updater": "Globally enable reading states from the KNX Bus", "rate_limit": "Maximum outgoing telegrams per second" } @@ -56,8 +58,7 @@ "data": { "port": "[%key:common::config_flow::data::port%]", "host": "[%key:common::config_flow::data::host%]", - "route_back": "Route Back / NAT Mode", - "local_ip": "Local IP (leave empty if unsure)" + "route_back": "Route Back / NAT Mode" } } } diff --git a/homeassistant/components/knx/translations/en.json b/homeassistant/components/knx/translations/en.json index 5320f0cfb03..91b9dfce5f3 100644 --- a/homeassistant/components/knx/translations/en.json +++ b/homeassistant/components/knx/translations/en.json @@ -22,7 +22,8 @@ "data": { "individual_address": "Individual address for the routing connection", "multicast_group": "The multicast group used for routing", - "multicast_port": "The multicast port used for routing" + "multicast_port": "The multicast port used for routing", + "local_ip": "Local IP (leave empty if unsure)" }, "description": "Please configure the routing options." }, @@ -48,6 +49,7 @@ "individual_address": "Default individual address", "multicast_group": "Multicast group used for routing and discovery", "multicast_port": "Multicast port used for routing and discovery", + "local_ip": "Local IP (leave empty if unsure)", "rate_limit": "Maximum outgoing telegrams per second", "state_updater": "Globally enable reading states from the KNX Bus" } @@ -55,7 +57,6 @@ "tunnel": { "data": { "host": "Host", - "local_ip": "Local IP (leave empty if unsure)", "port": "Port", "route_back": "Route Back / NAT Mode" } diff --git a/homeassistant/components/lupusec/manifest.json b/homeassistant/components/lupusec/manifest.json index ce200fe196a..126fa407a37 100644 --- a/homeassistant/components/lupusec/manifest.json +++ b/homeassistant/components/lupusec/manifest.json @@ -1,9 +1,8 @@ { - "disabled": "Library has incompatible requirements.", "domain": "lupusec", "name": "Lupus Electronics LUPUSEC", "documentation": "https://www.home-assistant.io/integrations/lupusec", - "requirements": ["lupupy==0.0.21"], + "requirements": ["lupupy==0.0.24"], "codeowners": ["@majuss"], "iot_class": "local_polling" } diff --git a/homeassistant/components/melcloud/manifest.json b/homeassistant/components/melcloud/manifest.json index f875984453d..355f4c9058b 100644 --- a/homeassistant/components/melcloud/manifest.json +++ b/homeassistant/components/melcloud/manifest.json @@ -3,7 +3,7 @@ "name": "MELCloud", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/melcloud", - "requirements": ["pymelcloud==2.5.5"], + "requirements": ["pymelcloud==2.5.6"], "codeowners": ["@vilppuvuorinen"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index 507711c73ff..0c14e59babc 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "dependencies": ["ffmpeg", "http", "media_source"], "documentation": "https://www.home-assistant.io/integrations/nest", - "requirements": ["python-nest==4.1.0", "google-nest-sdm==0.4.6"], + "requirements": ["python-nest==4.1.0", "google-nest-sdm==0.4.8"], "codeowners": ["@allenporter"], "quality_scale": "platinum", "dhcp": [ diff --git a/homeassistant/components/nest/media_source.py b/homeassistant/components/nest/media_source.py index 7c33e004b2b..8b62f2f4087 100644 --- a/homeassistant/components/nest/media_source.py +++ b/homeassistant/components/nest/media_source.py @@ -63,6 +63,9 @@ async def async_get_media_source(hass: HomeAssistant) -> MediaSource: async def get_media_source_devices(hass: HomeAssistant) -> Mapping[str, Device]: """Return a mapping of device id to eligible Nest event media devices.""" + if DATA_SUBSCRIBER not in hass.data[DOMAIN]: + # Integration unloaded, or is legacy nest integration + return {} subscriber = hass.data[DOMAIN][DATA_SUBSCRIBER] device_manager = await subscriber.async_get_device_manager() device_registry = await hass.helpers.device_registry.async_get_registry() diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index cd04de5d34c..dd3ff717e28 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -612,10 +612,24 @@ class SimpliSafe: data={**self.entry.data, CONF_TOKEN: token}, ) + @callback + def async_handle_refresh_token(token: str) -> None: + """Handle a new refresh token.""" + async_save_refresh_token(token) + + if TYPE_CHECKING: + assert self._api.websocket + + if self._api.websocket.connected: + # If a websocket connection is open, reconnect it to use the + # new access token: + asyncio.create_task(self._api.websocket.async_reconnect()) + self.entry.async_on_unload( - self._api.add_refresh_token_callback(async_save_refresh_token) + self._api.add_refresh_token_callback(async_handle_refresh_token) ) + # Save the refresh token we got on entry setup: async_save_refresh_token(self._api.refresh_token) async def async_update(self) -> None: diff --git a/homeassistant/components/smappee/manifest.json b/homeassistant/components/smappee/manifest.json index 91192a13484..6a1edaf41ae 100644 --- a/homeassistant/components/smappee/manifest.json +++ b/homeassistant/components/smappee/manifest.json @@ -5,7 +5,7 @@ "documentation": "https://www.home-assistant.io/integrations/smappee", "dependencies": ["http"], "requirements": [ - "pysmappee==0.2.27" + "pysmappee==0.2.29" ], "codeowners": [ "@bsmappee" diff --git a/homeassistant/components/tailscale/manifest.json b/homeassistant/components/tailscale/manifest.json index 4d47e397b76..eaa51855d38 100644 --- a/homeassistant/components/tailscale/manifest.json +++ b/homeassistant/components/tailscale/manifest.json @@ -3,7 +3,7 @@ "name": "Tailscale", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tailscale", - "requirements": ["tailscale==0.1.4"], + "requirements": ["tailscale==0.1.5"], "codeowners": ["@frenck"], "quality_scale": "platinum", "iot_class": "cloud_polling" diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index a653e91b991..c0b047a2856 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -2,7 +2,7 @@ "domain": "tibber", "name": "Tibber", "documentation": "https://www.home-assistant.io/integrations/tibber", - "requirements": ["pyTibber==0.21.0"], + "requirements": ["pyTibber==0.21.1"], "codeowners": ["@danielhiversen"], "quality_scale": "silver", "config_flow": true, diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json index 0eec41968cc..15854881ae3 100644 --- a/homeassistant/components/totalconnect/manifest.json +++ b/homeassistant/components/totalconnect/manifest.json @@ -2,7 +2,7 @@ "domain": "totalconnect", "name": "Total Connect", "documentation": "https://www.home-assistant.io/integrations/totalconnect", - "requirements": ["total_connect_client==2021.11.4"], + "requirements": ["total_connect_client==2021.12"], "dependencies": [], "codeowners": ["@austinmroczek"], "config_flow": true, diff --git a/homeassistant/components/xiaomi_miio/manifest.json b/homeassistant/components/xiaomi_miio/manifest.json index 757fca8be1f..8de844cdd44 100644 --- a/homeassistant/components/xiaomi_miio/manifest.json +++ b/homeassistant/components/xiaomi_miio/manifest.json @@ -3,7 +3,7 @@ "name": "Xiaomi Miio", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/xiaomi_miio", - "requirements": ["construct==2.10.56", "micloud==0.4", "python-miio==0.5.9.1"], + "requirements": ["construct==2.10.56", "micloud==0.4", "python-miio==0.5.9.2"], "codeowners": ["@rytilahti", "@syssi", "@starkillerOG", "@bieniu"], "zeroconf": ["_miio._udp.local."], "iot_class": "local_polling" diff --git a/homeassistant/components/zha/climate.py b/homeassistant/components/zha/climate.py index f7a1d1815db..d57fb21b4a3 100644 --- a/homeassistant/components/zha/climate.py +++ b/homeassistant/components/zha/climate.py @@ -206,11 +206,11 @@ class Thermostat(ZhaEntity, ClimateEntity): unoccupied_cooling_setpoint = self._thrm.unoccupied_cooling_setpoint if unoccupied_cooling_setpoint is not None: - data[ATTR_UNOCCP_HEAT_SETPT] = unoccupied_cooling_setpoint + data[ATTR_UNOCCP_COOL_SETPT] = unoccupied_cooling_setpoint unoccupied_heating_setpoint = self._thrm.unoccupied_heating_setpoint if unoccupied_heating_setpoint is not None: - data[ATTR_UNOCCP_COOL_SETPT] = unoccupied_heating_setpoint + data[ATTR_UNOCCP_HEAT_SETPT] = unoccupied_heating_setpoint return data @property diff --git a/homeassistant/const.py b/homeassistant/const.py index 00e2b23df4e..6fd2760d618 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from homeassistant.backports.enum import StrEnum MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 12 -PATCH_VERSION: Final = "1" +PATCH_VERSION: Final = "2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6c86e5804d8..e52542ccea0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ ciso8601==2.2.0 cryptography==35.0.0 emoji==1.5.0 hass-nabucasa==0.50.0 -home-assistant-frontend==20211212.0 +home-assistant-frontend==20211215.0 httpx==0.21.0 ifaddr==0.1.7 jinja2==3.0.3 diff --git a/requirements_all.txt b/requirements_all.txt index 8e079958d13..0631714600b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -186,7 +186,7 @@ aiohomekit==0.6.4 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==3.0.3 +aiohue==3.0.5 # homeassistant.components.imap aioimaplib==0.9.0 @@ -440,7 +440,7 @@ brother==1.1.0 brottsplatskartan==0.0.1 # homeassistant.components.brunt -brunt==1.0.0 +brunt==1.0.1 # homeassistant.components.bsblan bsblan==0.4.0 @@ -600,7 +600,7 @@ enocean==0.50 enturclient==0.2.2 # homeassistant.components.environment_canada -env_canada==0.5.18 +env_canada==0.5.20 # homeassistant.components.envirophat # envirophat==0.0.6 @@ -738,7 +738,7 @@ google-cloud-pubsub==2.1.0 google-cloud-texttospeech==0.4.0 # homeassistant.components.nest -google-nest-sdm==0.4.6 +google-nest-sdm==0.4.8 # homeassistant.components.google_travel_time googlemaps==2.5.1 @@ -819,7 +819,7 @@ hole==0.7.0 holidays==0.11.3.1 # homeassistant.components.frontend -home-assistant-frontend==20211212.0 +home-assistant-frontend==20211215.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 @@ -968,6 +968,9 @@ london-tube-status==0.2 # homeassistant.components.luftdaten luftdaten==0.7.1 +# homeassistant.components.lupusec +lupupy==0.0.24 + # homeassistant.components.lw12wifi lw12==0.9.2 @@ -1324,7 +1327,7 @@ pyRFXtrx==0.27.0 # pySwitchmate==0.4.6 # homeassistant.components.tibber -pyTibber==0.21.0 +pyTibber==0.21.1 # homeassistant.components.dlink pyW215==0.7.0 @@ -1393,7 +1396,7 @@ pycfdns==1.2.2 pychannels==1.0.0 # homeassistant.components.cast -pychromecast==10.1.1 +pychromecast==10.2.1 # homeassistant.components.pocketcasts pycketcasts==1.0.0 @@ -1628,7 +1631,7 @@ pymazda==0.2.2 pymediaroom==0.6.4.1 # homeassistant.components.melcloud -pymelcloud==2.5.5 +pymelcloud==2.5.6 # homeassistant.components.meteoclimatic pymeteoclimatic==0.0.6 @@ -1802,7 +1805,7 @@ pyskyqhub==0.1.3 pysma==0.6.9 # homeassistant.components.smappee -pysmappee==0.2.27 +pysmappee==0.2.29 # homeassistant.components.smartthings pysmartapp==0.3.3 @@ -1901,7 +1904,7 @@ python-kasa==0.4.0 # python-lirc==1.2.3 # homeassistant.components.xiaomi_miio -python-miio==0.5.9.1 +python-miio==0.5.9.2 # homeassistant.components.mpd python-mpd2==3.0.4 @@ -2269,7 +2272,7 @@ systembridge==2.2.3 tahoma-api==0.0.16 # homeassistant.components.tailscale -tailscale==0.1.4 +tailscale==0.1.5 # homeassistant.components.tank_utility tank_utility==1.4.0 @@ -2326,7 +2329,7 @@ tololib==0.1.0b3 toonapi==0.2.1 # homeassistant.components.totalconnect -total_connect_client==2021.11.4 +total_connect_client==2021.12 # homeassistant.components.tplink_lte tp-connected==0.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e1f9e1f7351..780a343463f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -131,7 +131,7 @@ aiohomekit==0.6.4 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==3.0.3 +aiohue==3.0.5 # homeassistant.components.apache_kafka aiokafka==0.6.0 @@ -281,7 +281,7 @@ broadlink==0.18.0 brother==1.1.0 # homeassistant.components.brunt -brunt==1.0.0 +brunt==1.0.1 # homeassistant.components.bsblan bsblan==0.4.0 @@ -375,7 +375,7 @@ emulated_roku==0.2.1 enocean==0.50 # homeassistant.components.environment_canada -env_canada==0.5.18 +env_canada==0.5.20 # homeassistant.components.enphase_envoy envoy_reader==0.20.1 @@ -461,7 +461,7 @@ google-api-python-client==1.6.4 google-cloud-pubsub==2.1.0 # homeassistant.components.nest -google-nest-sdm==0.4.6 +google-nest-sdm==0.4.8 # homeassistant.components.google_travel_time googlemaps==2.5.1 @@ -515,7 +515,7 @@ hole==0.7.0 holidays==0.11.3.1 # homeassistant.components.frontend -home-assistant-frontend==20211212.0 +home-assistant-frontend==20211215.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 @@ -808,7 +808,7 @@ pyMetno==0.9.0 pyRFXtrx==0.27.0 # homeassistant.components.tibber -pyTibber==0.21.0 +pyTibber==0.21.1 # homeassistant.components.nextbus py_nextbusnext==0.1.5 @@ -850,7 +850,7 @@ pybotvac==0.0.22 pycfdns==1.2.2 # homeassistant.components.cast -pychromecast==10.1.1 +pychromecast==10.2.1 # homeassistant.components.climacell pyclimacell==0.18.2 @@ -995,7 +995,7 @@ pymata-express==1.19 pymazda==0.2.2 # homeassistant.components.melcloud -pymelcloud==2.5.5 +pymelcloud==2.5.6 # homeassistant.components.meteoclimatic pymeteoclimatic==0.0.6 @@ -1109,7 +1109,7 @@ pysignalclirestapi==0.3.4 pysma==0.6.9 # homeassistant.components.smappee -pysmappee==0.2.27 +pysmappee==0.2.29 # homeassistant.components.smartthings pysmartapp==0.3.3 @@ -1145,7 +1145,7 @@ python-juicenet==1.0.2 python-kasa==0.4.0 # homeassistant.components.xiaomi_miio -python-miio==0.5.9.1 +python-miio==0.5.9.2 # homeassistant.components.nest python-nest==4.1.0 @@ -1352,7 +1352,7 @@ surepy==0.7.2 systembridge==2.2.3 # homeassistant.components.tailscale -tailscale==0.1.4 +tailscale==0.1.5 # homeassistant.components.tellduslive tellduslive==0.10.11 @@ -1370,7 +1370,7 @@ tololib==0.1.0b3 toonapi==0.2.1 # homeassistant.components.totalconnect -total_connect_client==2021.11.4 +total_connect_client==2021.12 # homeassistant.components.transmission transmissionrpc==0.11 diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index adab55c50df..85562f39761 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -754,7 +754,7 @@ async def test_supported_features( assert state.attributes.get("supported_features") == supported_features -async def test_entity_play_media(hass: HomeAssistant): +async def test_entity_play_media(hass: HomeAssistant, quick_play_mock): """Test playing media.""" entity_id = "media_player.speaker" reg = er.async_get(hass) @@ -776,8 +776,28 @@ async def test_entity_play_media(hass: HomeAssistant): assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) # Play_media - await common.async_play_media(hass, "audio", "best.mp3", entity_id) - chromecast.media_controller.play_media.assert_called_once_with("best.mp3", "audio") + await hass.services.async_call( + media_player.DOMAIN, + media_player.SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: entity_id, + media_player.ATTR_MEDIA_CONTENT_TYPE: "audio", + media_player.ATTR_MEDIA_CONTENT_ID: "best.mp3", + media_player.ATTR_MEDIA_EXTRA: {"metadata": {"metadatatype": 3}}, + }, + blocking=True, + ) + + chromecast.media_controller.play_media.assert_not_called() + quick_play_mock.assert_called_once_with( + chromecast, + "homeassistant_media", + { + "media_id": "best.mp3", + "media_type": "audio", + "metadata": {"metadatatype": 3}, + }, + ) async def test_entity_play_media_cast(hass: HomeAssistant, quick_play_mock): @@ -865,7 +885,7 @@ async def test_entity_play_media_cast_invalid(hass, caplog, quick_play_mock): assert "App unknown not supported" in caplog.text -async def test_entity_play_media_sign_URL(hass: HomeAssistant): +async def test_entity_play_media_sign_URL(hass: HomeAssistant, quick_play_mock): """Test playing media.""" entity_id = "media_player.speaker" @@ -886,8 +906,10 @@ async def test_entity_play_media_sign_URL(hass: HomeAssistant): # Play_media await common.async_play_media(hass, "audio", "/best.mp3", entity_id) - chromecast.media_controller.play_media.assert_called_once_with(ANY, "audio") - assert chromecast.media_controller.play_media.call_args[0][0].startswith( + quick_play_mock.assert_called_once_with( + chromecast, "homeassistant_media", {"media_id": ANY, "media_type": "audio"} + ) + assert quick_play_mock.call_args[0][2]["media_id"].startswith( "http://example.com:8123/best.mp3?authSig=" ) @@ -1231,7 +1253,7 @@ async def test_group_media_states(hass, mz_mock): assert state.state == "playing" -async def test_group_media_control(hass, mz_mock): +async def test_group_media_control(hass, mz_mock, quick_play_mock): """Test media controls are handled by group if entity has no state.""" entity_id = "media_player.speaker" reg = er.async_get(hass) @@ -1286,7 +1308,12 @@ async def test_group_media_control(hass, mz_mock): # Verify play_media is not forwarded await common.async_play_media(hass, "music", "best.mp3", entity_id) assert not grp_media.play_media.called - assert chromecast.media_controller.play_media.called + assert not chromecast.media_controller.play_media.called + quick_play_mock.assert_called_once_with( + chromecast, + "homeassistant_media", + {"media_id": "best.mp3", "media_type": "music"}, + ) async def test_failed_cast_on_idle(hass, caplog): diff --git a/tests/components/hue/test_light_v2.py b/tests/components/hue/test_light_v2.py index 362b7076a92..70a5af6d98e 100644 --- a/tests/components/hue/test_light_v2.py +++ b/tests/components/hue/test_light_v2.py @@ -121,6 +121,17 @@ async def test_light_turn_on_service(hass, mock_bridge_v2, v2_resources_test_dat assert mock_bridge_v2.mock_requests[1]["json"]["on"]["on"] is True assert mock_bridge_v2.mock_requests[1]["json"]["dynamics"]["duration"] == 6000 + # test again with sending flash/alert + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": test_light_id, "flash": "long"}, + blocking=True, + ) + assert len(mock_bridge_v2.mock_requests) == 3 + assert mock_bridge_v2.mock_requests[2]["json"]["on"]["on"] is True + assert mock_bridge_v2.mock_requests[2]["json"]["alert"]["action"] == "breathe" + async def test_light_turn_off_service(hass, mock_bridge_v2, v2_resources_test_data): """Test calling the turn off service on a light.""" @@ -295,7 +306,12 @@ async def test_grouped_lights(hass, mock_bridge_v2, v2_resources_test_data): await hass.services.async_call( "light", "turn_on", - {"entity_id": test_light_id, "brightness_pct": 100, "xy_color": (0.123, 0.123)}, + { + "entity_id": test_light_id, + "brightness_pct": 100, + "xy_color": (0.123, 0.123), + "transition": 6, + }, blocking=True, ) @@ -308,6 +324,9 @@ async def test_grouped_lights(hass, mock_bridge_v2, v2_resources_test_data): ) assert mock_bridge_v2.mock_requests[index]["json"]["color"]["xy"]["x"] == 0.123 assert mock_bridge_v2.mock_requests[index]["json"]["color"]["xy"]["y"] == 0.123 + assert ( + mock_bridge_v2.mock_requests[index]["json"]["dynamics"]["duration"] == 6000 + ) # Now generate update events by emitting the json we've sent as incoming events for index in range(0, 3): @@ -346,3 +365,24 @@ async def test_grouped_lights(hass, mock_bridge_v2, v2_resources_test_data): test_light = hass.states.get(test_light_id) assert test_light is not None assert test_light.state == "off" + + # Test calling the turn off service on a grouped light with transition + mock_bridge_v2.mock_requests.clear() + test_light_id = "light.test_zone" + await hass.services.async_call( + "light", + "turn_off", + { + "entity_id": test_light_id, + "transition": 6, + }, + blocking=True, + ) + + # PUT request should have been sent to ALL group lights with correct params + assert len(mock_bridge_v2.mock_requests) == 3 + for index in range(0, 3): + assert mock_bridge_v2.mock_requests[index]["json"]["on"]["on"] is False + assert ( + mock_bridge_v2.mock_requests[index]["json"]["dynamics"]["duration"] == 6000 + ) diff --git a/tests/components/knx/test_config_flow.py b/tests/components/knx/test_config_flow.py index ff1fc362aa5..65289c2b173 100644 --- a/tests/components/knx/test_config_flow.py +++ b/tests/components/knx/test_config_flow.py @@ -83,6 +83,60 @@ async def test_routing_setup(hass: HomeAssistant) -> None: CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + ConnectionSchema.CONF_KNX_LOCAL_IP: None, + CONF_KNX_INDIVIDUAL_ADDRESS: "1.1.110", + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_routing_setup_advanced(hass: HomeAssistant) -> None: + """Test routing setup with advanced options.""" + with patch("xknx.io.gateway_scanner.GatewayScanner.scan") as gateways: + gateways.return_value = [] + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_USER, + "show_advanced_options": True, + }, + ) + assert result["type"] == RESULT_TYPE_FORM + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, + }, + ) + await hass.async_block_till_done() + assert result2["type"] == RESULT_TYPE_FORM + assert result2["step_id"] == "routing" + assert not result2["errors"] + + with patch( + "homeassistant.components.knx.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + CONF_KNX_INDIVIDUAL_ADDRESS: "1.1.110", + ConnectionSchema.CONF_KNX_LOCAL_IP: "192.168.1.112", + }, + ) + await hass.async_block_till_done() + assert result3["type"] == RESULT_TYPE_CREATE_ENTRY + assert result3["title"] == CONF_KNX_ROUTING.capitalize() + assert result3["data"] == { + **DEFAULT_ENTRY_DATA, + CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + ConnectionSchema.CONF_KNX_LOCAL_IP: "192.168.1.112", CONF_KNX_INDIVIDUAL_ADDRESS: "1.1.110", } @@ -144,7 +198,11 @@ async def test_tunneling_setup_for_local_ip(hass: HomeAssistant) -> None: with patch("xknx.io.gateway_scanner.GatewayScanner.scan") as gateways: gateways.return_value = [gateway] result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, + context={ + "source": config_entries.SOURCE_USER, + "show_advanced_options": True, + }, ) assert result["type"] == RESULT_TYPE_FORM assert not result["errors"] @@ -563,7 +621,6 @@ async def test_tunneling_options_flow( CONF_HOST: "192.168.1.1", CONF_PORT: 3675, ConnectionSchema.CONF_KNX_ROUTE_BACK: True, - ConnectionSchema.CONF_KNX_LOCAL_IP: "192.168.1.112", }, ) @@ -581,7 +638,6 @@ async def test_tunneling_options_flow( CONF_HOST: "192.168.1.1", CONF_PORT: 3675, ConnectionSchema.CONF_KNX_ROUTE_BACK: True, - ConnectionSchema.CONF_KNX_LOCAL_IP: "192.168.1.112", } @@ -611,6 +667,7 @@ async def test_advanced_options( ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, ConnectionSchema.CONF_KNX_RATE_LIMIT: 25, ConnectionSchema.CONF_KNX_STATE_UPDATER: False, + ConnectionSchema.CONF_KNX_LOCAL_IP: "192.168.1.112", }, ) @@ -626,4 +683,5 @@ async def test_advanced_options( ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, ConnectionSchema.CONF_KNX_RATE_LIMIT: 25, ConnectionSchema.CONF_KNX_STATE_UPDATER: False, + ConnectionSchema.CONF_KNX_LOCAL_IP: "192.168.1.112", } diff --git a/tests/components/knx/test_init.py b/tests/components/knx/test_init.py new file mode 100644 index 00000000000..4380b132cbd --- /dev/null +++ b/tests/components/knx/test_init.py @@ -0,0 +1,78 @@ +"""Test KNX init.""" +import pytest +from xknx import XKNX +from xknx.io import ConnectionConfig, ConnectionType + +from homeassistant.components.knx.const import ( + CONF_KNX_AUTOMATIC, + CONF_KNX_CONNECTION_TYPE, + CONF_KNX_INDIVIDUAL_ADDRESS, + CONF_KNX_ROUTING, + CONF_KNX_TUNNELING, + DOMAIN as KNX_DOMAIN, +) +from homeassistant.components.knx.schema import ConnectionSchema +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant + +from .conftest import KNXTestKit + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + "config_entry_data,connection_config", + [ + ( + { + CONF_KNX_INDIVIDUAL_ADDRESS: XKNX.DEFAULT_ADDRESS, + CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, + }, + ConnectionConfig(), + ), + ( + { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, + ConnectionSchema.CONF_KNX_LOCAL_IP: "192.168.1.1", + }, + ConnectionConfig( + connection_type=ConnectionType.ROUTING, local_ip="192.168.1.1" + ), + ), + ( + { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, + CONF_HOST: "192.168.0.2", + CONF_PORT: 3675, + ConnectionSchema.CONF_KNX_ROUTE_BACK: False, + ConnectionSchema.CONF_KNX_LOCAL_IP: "192.168.1.112", + }, + ConnectionConfig( + connection_type=ConnectionType.TUNNELING, + route_back=False, + gateway_ip="192.168.0.2", + gateway_port=3675, + local_ip="192.168.1.112", + auto_reconnect=True, + ), + ), + ], +) +async def test_init_connection_handling( + hass: HomeAssistant, knx: KNXTestKit, config_entry_data, connection_config +): + """Test correctly generating connection config.""" + + config_entry = MockConfigEntry( + title="KNX", + domain=KNX_DOMAIN, + data=config_entry_data, + ) + knx.mock_config_entry = config_entry + await knx.setup_integration({}) + + assert hass.data.get(KNX_DOMAIN) is not None + + assert ( + hass.data[KNX_DOMAIN].connection_config().__dict__ == connection_config.__dict__ + ) diff --git a/tests/components/nest/test_events.py b/tests/components/nest/test_events.py index 4767fd815d2..4a625999155 100644 --- a/tests/components/nest/test_events.py +++ b/tests/components/nest/test_events.py @@ -39,13 +39,12 @@ async def async_setup_devices(hass, device_type, traits={}): return await async_setup_sdm_platform(hass, PLATFORM, devices=devices) -def create_device_traits(event_trait): +def create_device_traits(event_traits=[]): """Create fake traits for a device.""" - return { + result = { "sdm.devices.traits.Info": { "customName": "Front", }, - event_trait: {}, "sdm.devices.traits.CameraLiveStream": { "maxVideoResolution": { "width": 640, @@ -55,6 +54,8 @@ def create_device_traits(event_trait): "audioCodecs": ["AAC"], }, } + result.update({t: {} for t in event_traits}) + return result def create_event(event_type, device_id=DEVICE_ID, timestamp=None): @@ -91,7 +92,7 @@ async def test_doorbell_chime_event(hass): subscriber = await async_setup_devices( hass, "sdm.devices.types.DOORBELL", - create_device_traits("sdm.devices.traits.DoorbellChime"), + create_device_traits(["sdm.devices.traits.DoorbellChime"]), ) registry = er.async_get(hass) @@ -129,7 +130,7 @@ async def test_camera_motion_event(hass): subscriber = await async_setup_devices( hass, "sdm.devices.types.CAMERA", - create_device_traits("sdm.devices.traits.CameraMotion"), + create_device_traits(["sdm.devices.traits.CameraMotion"]), ) registry = er.async_get(hass) entry = registry.async_get("camera.front") @@ -157,7 +158,7 @@ async def test_camera_sound_event(hass): subscriber = await async_setup_devices( hass, "sdm.devices.types.CAMERA", - create_device_traits("sdm.devices.traits.CameraSound"), + create_device_traits(["sdm.devices.traits.CameraSound"]), ) registry = er.async_get(hass) entry = registry.async_get("camera.front") @@ -185,7 +186,7 @@ async def test_camera_person_event(hass): subscriber = await async_setup_devices( hass, "sdm.devices.types.DOORBELL", - create_device_traits("sdm.devices.traits.CameraEventImage"), + create_device_traits(["sdm.devices.traits.CameraPerson"]), ) registry = er.async_get(hass) entry = registry.async_get("camera.front") @@ -213,7 +214,9 @@ async def test_camera_multiple_event(hass): subscriber = await async_setup_devices( hass, "sdm.devices.types.DOORBELL", - create_device_traits("sdm.devices.traits.CameraEventImage"), + create_device_traits( + ["sdm.devices.traits.CameraMotion", "sdm.devices.traits.CameraPerson"] + ), ) registry = er.async_get(hass) entry = registry.async_get("camera.front") @@ -256,7 +259,7 @@ async def test_unknown_event(hass): subscriber = await async_setup_devices( hass, "sdm.devices.types.DOORBELL", - create_device_traits("sdm.devices.traits.DoorbellChime"), + create_device_traits(["sdm.devices.traits.DoorbellChime"]), ) await subscriber.async_receive_event(create_event("some-event-id")) await hass.async_block_till_done() @@ -270,7 +273,7 @@ async def test_unknown_device_id(hass): subscriber = await async_setup_devices( hass, "sdm.devices.types.DOORBELL", - create_device_traits("sdm.devices.traits.DoorbellChime"), + create_device_traits(["sdm.devices.traits.DoorbellChime"]), ) await subscriber.async_receive_event( create_event("sdm.devices.events.DoorbellChime.Chime", "invalid-device-id") @@ -286,7 +289,7 @@ async def test_event_message_without_device_event(hass): subscriber = await async_setup_devices( hass, "sdm.devices.types.DOORBELL", - create_device_traits("sdm.devices.traits.DoorbellChime"), + create_device_traits(["sdm.devices.traits.DoorbellChime"]), ) timestamp = utcnow() event = EventMessage( @@ -308,14 +311,12 @@ async def test_doorbell_event_thread(hass): subscriber = await async_setup_devices( hass, "sdm.devices.types.DOORBELL", - traits={ - "sdm.devices.traits.Info": { - "customName": "Front", - }, - "sdm.devices.traits.CameraLiveStream": {}, - "sdm.devices.traits.CameraClipPreview": {}, - "sdm.devices.traits.CameraPerson": {}, - }, + create_device_traits( + [ + "sdm.devices.traits.CameraClipPreview", + "sdm.devices.traits.CameraPerson", + ] + ), ) registry = er.async_get(hass) entry = registry.async_get("camera.front") @@ -351,7 +352,7 @@ async def test_doorbell_event_thread(hass): ) await subscriber.async_receive_event(EventMessage(message_data_1, auth=None)) - # Publish message #1 that sends a no-op update to end the event thread + # Publish message #2 that sends a no-op update to end the event thread timestamp2 = timestamp1 + datetime.timedelta(seconds=1) message_data_2 = event_message_data.copy() message_data_2.update( @@ -371,3 +372,77 @@ async def test_doorbell_event_thread(hass): "timestamp": timestamp1.replace(microsecond=0), "nest_event_id": EVENT_SESSION_ID, } + + +async def test_doorbell_event_session_update(hass): + """Test a pubsub message with updates to an existing session.""" + events = async_capture_events(hass, NEST_EVENT) + subscriber = await async_setup_devices( + hass, + "sdm.devices.types.DOORBELL", + create_device_traits( + [ + "sdm.devices.traits.CameraClipPreview", + "sdm.devices.traits.CameraPerson", + "sdm.devices.traits.CameraMotion", + ] + ), + ) + registry = er.async_get(hass) + entry = registry.async_get("camera.front") + assert entry is not None + + # Message #1 has a motion event + timestamp1 = utcnow() + await subscriber.async_receive_event( + create_events( + { + "sdm.devices.events.CameraMotion.Motion": { + "eventSessionId": EVENT_SESSION_ID, + "eventId": "n:1", + }, + "sdm.devices.events.CameraClipPreview.ClipPreview": { + "eventSessionId": EVENT_SESSION_ID, + "previewUrl": "image-url-1", + }, + }, + timestamp=timestamp1, + ) + ) + + # Message #2 has an extra person event + timestamp2 = utcnow() + await subscriber.async_receive_event( + create_events( + { + "sdm.devices.events.CameraMotion.Motion": { + "eventSessionId": EVENT_SESSION_ID, + "eventId": "n:1", + }, + "sdm.devices.events.CameraPerson.Person": { + "eventSessionId": EVENT_SESSION_ID, + "eventId": "n:2", + }, + "sdm.devices.events.CameraClipPreview.ClipPreview": { + "eventSessionId": EVENT_SESSION_ID, + "previewUrl": "image-url-1", + }, + }, + timestamp=timestamp2, + ) + ) + await hass.async_block_till_done() + + assert len(events) == 2 + assert events[0].data == { + "device_id": entry.device_id, + "type": "camera_motion", + "timestamp": timestamp1.replace(microsecond=0), + "nest_event_id": EVENT_SESSION_ID, + } + assert events[1].data == { + "device_id": entry.device_id, + "type": "camera_person", + "timestamp": timestamp2.replace(microsecond=0), + "nest_event_id": EVENT_SESSION_ID, + } diff --git a/tests/components/nest/test_media_source.py b/tests/components/nest/test_media_source.py index f52b89c4f4d..95f2afa8a06 100644 --- a/tests/components/nest/test_media_source.py +++ b/tests/components/nest/test_media_source.py @@ -16,6 +16,7 @@ from homeassistant.components import media_source from homeassistant.components.media_player.errors import BrowseError from homeassistant.components.media_source import const from homeassistant.components.media_source.error import Unresolvable +from homeassistant.config_entries import ConfigEntryState from homeassistant.helpers import device_registry as dr from homeassistant.helpers.template import DATE_STR_FORMAT import homeassistant.util.dt as dt_util @@ -164,6 +165,37 @@ async def test_supported_device(hass, auth): assert len(browse.children) == 0 +async def test_integration_unloaded(hass, auth): + """Test the media player loads, but has no devices, when config unloaded.""" + await async_setup_devices( + hass, + auth, + CAMERA_DEVICE_TYPE, + CAMERA_TRAITS, + ) + + browse = await media_source.async_browse_media(hass, f"{const.URI_SCHEME}{DOMAIN}") + assert browse.domain == DOMAIN + assert browse.identifier == "" + assert browse.title == "Nest" + assert len(browse.children) == 1 + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + assert entry.state == ConfigEntryState.NOT_LOADED + + # No devices returned + browse = await media_source.async_browse_media(hass, f"{const.URI_SCHEME}{DOMAIN}") + assert browse.domain == DOMAIN + assert browse.identifier == "" + assert browse.title == "Nest" + assert len(browse.children) == 0 + + async def test_camera_event(hass, auth, hass_client): """Test a media source and image created for an event.""" event_timestamp = dt_util.now()