diff --git a/homeassistant/components/auth/indieauth.py b/homeassistant/components/auth/indieauth.py index 478f7ab2831..ec8431366ab 100644 --- a/homeassistant/components/auth/indieauth.py +++ b/homeassistant/components/auth/indieauth.py @@ -47,6 +47,7 @@ async def verify_redirect_uri( if client_id == "https://home-assistant.io/android" and redirect_uri in ( "homeassistant://auth-callback", "https://wear.googleapis.com/3p_auth/io.homeassistant.companion.android", + "https://wear.googleapis-cn.com/3p_auth/io.homeassistant.companion.android", ): return True diff --git a/homeassistant/components/govee_ble/manifest.json b/homeassistant/components/govee_ble/manifest.json index ad54aa4bc43..aaa659d1667 100644 --- a/homeassistant/components/govee_ble/manifest.json +++ b/homeassistant/components/govee_ble/manifest.json @@ -73,7 +73,7 @@ "connectable": false } ], - "requirements": ["govee-ble==0.21.0"], + "requirements": ["govee-ble==0.21.1"], "dependencies": ["bluetooth"], "codeowners": ["@bdraco", "@PierreAronnax"], "iot_class": "local_push" diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py index 8d0dd49bff8..04b288d523b 100644 --- a/homeassistant/components/logbook/websocket_api.py +++ b/homeassistant/components/logbook/websocket_api.py @@ -83,6 +83,7 @@ async def _async_send_historical_events( formatter: Callable[[int, Any], dict[str, Any]], event_processor: EventProcessor, partial: bool, + force_send: bool = False, ) -> dt | None: """Select historical data from the database and deliver it to the websocket. @@ -116,7 +117,7 @@ async def _async_send_historical_events( # if its the last one (not partial) so # consumers of the api know their request was # answered but there were no results - if last_event_time or not partial: + if last_event_time or not partial or force_send: connection.send_message(message) return last_event_time @@ -150,7 +151,7 @@ async def _async_send_historical_events( # if its the last one (not partial) so # consumers of the api know their request was # answered but there were no results - if older_query_last_event_time or not partial: + if older_query_last_event_time or not partial or force_send: connection.send_message(older_message) # Returns the time of the newest event @@ -384,6 +385,11 @@ async def ws_event_stream( messages.event_message, event_processor, partial=True, + # Force a send since the wait for the sync task + # can take a a while if the recorder is busy and + # we want to make sure the client is not still spinning + # because it is waiting for the first message + force_send=True, ) live_stream.task = asyncio.create_task( diff --git a/homeassistant/components/matter/adapter.py b/homeassistant/components/matter/adapter.py index b573ed0a3fc..08763e38327 100644 --- a/homeassistant/components/matter/adapter.py +++ b/homeassistant/components/matter/adapter.py @@ -1,11 +1,15 @@ """Matter to Home Assistant adapter.""" from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from chip.clusters import Objects as all_clusters from matter_server.common.models.events import EventType -from matter_server.common.models.node_device import AbstractMatterNodeDevice +from matter_server.common.models.node_device import ( + AbstractMatterNodeDevice, + MatterBridgedNodeDevice, +) +from matter_server.common.models.server_information import ServerInfo from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -13,8 +17,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, LOGGER +from .const import DOMAIN, ID_TYPE_DEVICE_ID, ID_TYPE_SERIAL, LOGGER from .device_platform import DEVICE_PLATFORM +from .helpers import get_device_id if TYPE_CHECKING: from matter_server.client import MatterClient @@ -66,31 +71,56 @@ class MatterAdapter: bridge_unique_id: str | None = None if node.aggregator_device_type_instance is not None and ( - node_info := node.root_device_type_instance.get_cluster(all_clusters.Basic) + node.root_device_type_instance.get_cluster(all_clusters.Basic) ): - self._create_device_registry( - node_info, node_info.nodeLabel or "Hub device", None + # create virtual (parent) device for bridge node device + bridge_device = MatterBridgedNodeDevice( + node.aggregator_device_type_instance ) - bridge_unique_id = node_info.uniqueID + self._create_device_registry(bridge_device) + server_info = cast(ServerInfo, self.matter_client.server_info) + bridge_unique_id = get_device_id(server_info, bridge_device) for node_device in node.node_devices: self._setup_node_device(node_device, bridge_unique_id) def _create_device_registry( self, - info: all_clusters.Basic | all_clusters.BridgedDeviceBasic, - name: str, - bridge_unique_id: str | None, + node_device: AbstractMatterNodeDevice, + bridge_unique_id: str | None = None, ) -> None: """Create a device registry entry.""" + server_info = cast(ServerInfo, self.matter_client.server_info) + + basic_info = node_device.device_info() + device_type_instances = node_device.device_type_instances() + + name = basic_info.nodeLabel + if not name and isinstance(node_device, MatterBridgedNodeDevice): + # fallback name for Bridge + name = "Hub device" + elif not name and device_type_instances: + # use the productName if no node label is present + name = basic_info.productName + + node_device_id = get_device_id( + server_info, + node_device, + ) + identifiers = {(DOMAIN, f"{ID_TYPE_DEVICE_ID}_{node_device_id}")} + # if available, we also add the serialnumber as identifier + if basic_info.serialNumber and "test" not in basic_info.serialNumber.lower(): + # prefix identifier with 'serial_' to be able to filter it + identifiers.add((DOMAIN, f"{ID_TYPE_SERIAL}_{basic_info.serialNumber}")) + dr.async_get(self.hass).async_get_or_create( name=name, config_entry_id=self.config_entry.entry_id, - identifiers={(DOMAIN, info.uniqueID)}, - hw_version=info.hardwareVersionString, - sw_version=info.softwareVersionString, - manufacturer=info.vendorName, - model=info.productName, + identifiers=identifiers, + hw_version=basic_info.hardwareVersionString, + sw_version=basic_info.softwareVersionString, + manufacturer=basic_info.vendorName, + model=basic_info.productName, via_device=(DOMAIN, bridge_unique_id) if bridge_unique_id else None, ) @@ -98,17 +128,9 @@ class MatterAdapter: self, node_device: AbstractMatterNodeDevice, bridge_unique_id: str | None ) -> None: """Set up a node device.""" - node = node_device.node() - basic_info = node_device.device_info() - device_type_instances = node_device.device_type_instances() - - name = basic_info.nodeLabel - if not name and device_type_instances: - name = f"{device_type_instances[0].device_type.__doc__[:-1]} {node.node_id}" - - self._create_device_registry(basic_info, name, bridge_unique_id) - - for instance in device_type_instances: + self._create_device_registry(node_device, bridge_unique_id) + # run platform discovery from device type instances + for instance in node_device.device_type_instances(): created = False for platform, devices in DEVICE_PLATFORM.items(): diff --git a/homeassistant/components/matter/const.py b/homeassistant/components/matter/const.py index c5ec1173ac0..e7f96bd2448 100644 --- a/homeassistant/components/matter/const.py +++ b/homeassistant/components/matter/const.py @@ -8,3 +8,7 @@ CONF_USE_ADDON = "use_addon" DOMAIN = "matter" LOGGER = logging.getLogger(__package__) + +# prefixes to identify device identifier id types +ID_TYPE_DEVICE_ID = "deviceid" +ID_TYPE_SERIAL = "serial" diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index 4f28c1d2369..820d0f72846 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -5,16 +5,18 @@ from abc import abstractmethod from collections.abc import Callable from dataclasses import dataclass import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from matter_server.common.models.device_type_instance import MatterDeviceTypeInstance from matter_server.common.models.events import EventType from matter_server.common.models.node_device import AbstractMatterNodeDevice +from matter_server.common.models.server_information import ServerInfo from homeassistant.core import callback from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription -from .const import DOMAIN +from .const import DOMAIN, ID_TYPE_DEVICE_ID +from .helpers import get_device_id, get_operational_instance_id if TYPE_CHECKING: from matter_server.client import MatterClient @@ -55,24 +57,21 @@ class MatterEntity(Entity): self._node_device = node_device self._device_type_instance = device_type_instance self.entity_description = entity_description - node = device_type_instance.node self._unsubscribes: list[Callable] = [] # for fast lookups we create a mapping to the attribute paths - self._attributes_map: dict[type, str] = {} - server_info = matter_client.server_info # The server info is set when the client connects to the server. - assert server_info is not None + self._attributes_map: dict[type, str] = {} + server_info = cast(ServerInfo, self.matter_client.server_info) + # create unique_id based on "Operational Instance Name" and endpoint/device type self._attr_unique_id = ( - f"{server_info.compressed_fabric_id}-" - f"{node.unique_id}-" + f"{get_operational_instance_id(server_info, self._node_device.node())}-" f"{device_type_instance.endpoint}-" f"{device_type_instance.device_type.device_type}" ) - - @property - def device_info(self) -> DeviceInfo | None: - """Return device info for device registry.""" - return {"identifiers": {(DOMAIN, self._node_device.device_info().uniqueID)}} + node_device_id = get_device_id(server_info, node_device) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{ID_TYPE_DEVICE_ID}_{node_device_id}")} + ) async def async_added_to_hass(self) -> None: """Handle being added to Home Assistant.""" @@ -115,7 +114,7 @@ class MatterEntity(Entity): @callback def get_matter_attribute(self, attribute: type) -> MatterAttribute | None: - """Lookup MatterAttribute instance on device instance by providing the attribute class.""" + """Lookup MatterAttribute on device by providing the attribute class.""" return next( ( x diff --git a/homeassistant/components/matter/helpers.py b/homeassistant/components/matter/helpers.py index 479b1d824ad..5abf81ee608 100644 --- a/homeassistant/components/matter/helpers.py +++ b/homeassistant/components/matter/helpers.py @@ -10,6 +10,10 @@ from homeassistant.core import HomeAssistant, callback from .const import DOMAIN if TYPE_CHECKING: + from matter_server.common.models.node import MatterNode + from matter_server.common.models.node_device import AbstractMatterNodeDevice + from matter_server.common.models.server_information import ServerInfo + from .adapter import MatterAdapter @@ -25,7 +29,32 @@ class MatterEntryData: def get_matter(hass: HomeAssistant) -> MatterAdapter: """Return MatterAdapter instance.""" # NOTE: This assumes only one Matter connection/fabric can exist. - # Shall we support connecting to multiple servers in the client or by config entries? - # In case of the config entry we need to fix this. + # Shall we support connecting to multiple servers in the client or by + # config entries? In case of the config entry we need to fix this. matter_entry_data: MatterEntryData = next(iter(hass.data[DOMAIN].values())) return matter_entry_data.adapter + + +def get_operational_instance_id( + server_info: ServerInfo, + node: MatterNode, +) -> str: + """Return `Operational Instance Name` for given MatterNode.""" + fabric_id_hex = f"{server_info.compressed_fabric_id:016X}" + node_id_hex = f"{node.node_id:016X}" + # Operational instance id matches the mDNS advertisement for the node + # this is the recommended ID to recognize a unique matter node (within a fabric). + return f"{fabric_id_hex}-{node_id_hex}" + + +def get_device_id( + server_info: ServerInfo, + node_device: AbstractMatterNodeDevice, +) -> str: + """Return HA device_id for the given MatterNodeDevice.""" + operational_instance_id = get_operational_instance_id( + server_info, node_device.node() + ) + # Append nodedevice(type) to differentiate between a root node + # and bridge within Home Assistant devices. + return f"{operational_instance_id}-{node_device.__class__.__name__}" diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 12a46ee3ef9..96852512334 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -475,6 +475,11 @@ class ShellyRpcCoordinator(DataUpdateCoordinator[None]): async def _async_disconnected(self) -> None: """Handle device disconnected.""" + # Sleeping devices send data and disconnects + # There are no disconnect events for sleeping devices + if self.entry.data.get(CONF_SLEEP_PERIOD): + return + async with self._connection_lock: if not self.connected: # Already disconnected return diff --git a/homeassistant/const.py b/homeassistant/const.py index d9eb4bb8534..d14ce310238 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -8,7 +8,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "5" +PATCH_VERSION: Final = "6" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0) diff --git a/pyproject.toml b/pyproject.toml index c1d8bb8caa2..6fd631b63c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.1.5" +version = "2023.1.6" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" diff --git a/requirements_all.txt b/requirements_all.txt index 5e812d80058..3f6fecf57da 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -803,7 +803,7 @@ googlemaps==2.5.1 goslide-api==0.5.1 # homeassistant.components.govee_ble -govee-ble==0.21.0 +govee-ble==0.21.1 # homeassistant.components.remote_rpi_gpio gpiozero==1.6.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 214c868043c..fa5c72da0af 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -607,7 +607,7 @@ google-nest-sdm==2.2.2 googlemaps==2.5.1 # homeassistant.components.govee_ble -govee-ble==0.21.0 +govee-ble==0.21.1 # homeassistant.components.gree greeclimate==1.3.0 diff --git a/tests/components/auth/test_indieauth.py b/tests/components/auth/test_indieauth.py index 17d1fa927a0..43bd6b71fe5 100644 --- a/tests/components/auth/test_indieauth.py +++ b/tests/components/auth/test_indieauth.py @@ -190,9 +190,19 @@ async def test_verify_redirect_uri_android_ios(client_id): client_id, "https://wear.googleapis.com/3p_auth/io.homeassistant.companion.android", ) + assert await indieauth.verify_redirect_uri( + None, + client_id, + "https://wear.googleapis-cn.com/3p_auth/io.homeassistant.companion.android", + ) else: assert not await indieauth.verify_redirect_uri( None, client_id, "https://wear.googleapis.com/3p_auth/io.homeassistant.companion.android", ) + assert not await indieauth.verify_redirect_uri( + None, + client_id, + "https://wear.googleapis-cn.com/3p_auth/io.homeassistant.companion.android", + ) diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index 5b16c98998c..91d1a95f75b 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -1817,6 +1817,7 @@ async def test_subscribe_unsubscribe_logbook_stream_device( assert msg["id"] == 7 assert msg["type"] == TYPE_RESULT assert msg["success"] + await async_wait_recording_done(hass) # There are no answers to our initial query # so we get an empty reply. This is to ensure @@ -1828,6 +1829,15 @@ async def test_subscribe_unsubscribe_logbook_stream_device( assert msg["id"] == 7 assert msg["type"] == "event" assert msg["event"]["events"] == [] + assert "partial" in msg["event"] + await async_wait_recording_done(hass) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [] + assert "partial" not in msg["event"] + await async_wait_recording_done(hass) hass.states.async_set("binary_sensor.should_not_appear", STATE_ON) hass.states.async_set("binary_sensor.should_not_appear", STATE_OFF) @@ -1942,6 +1952,14 @@ async def test_logbook_stream_match_multiple_entities( assert msg["id"] == 7 assert msg["type"] == "event" assert msg["event"]["events"] == [] + assert "partial" in msg["event"] + await async_wait_recording_done(hass) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [] + assert "partial" not in msg["event"] await async_wait_recording_done(hass) hass.states.async_set("binary_sensor.should_not_appear", STATE_ON) diff --git a/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json b/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json index cbbe39b1f09..e26450a9a28 100644 --- a/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json +++ b/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json @@ -175,7 +175,7 @@ "attribute_id": 5, "attribute_type": "chip.clusters.Objects.Basic.Attributes.NodeLabel", "attribute_name": "NodeLabel", - "value": "Mock OnOff Plugin Unit" + "value": "" }, "0/40/6": { "node_id": 1, diff --git a/tests/components/matter/fixtures/nodes/onoff-light.json b/tests/components/matter/fixtures/nodes/onoff-light.json index cc6521aa2e3..340d7cb71c9 100644 --- a/tests/components/matter/fixtures/nodes/onoff-light.json +++ b/tests/components/matter/fixtures/nodes/onoff-light.json @@ -469,7 +469,7 @@ "attribute_id": 15, "attribute_type": "chip.clusters.Objects.Basic.Attributes.SerialNumber", "attribute_name": "SerialNumber", - "value": "TEST_SN" + "value": "12345678" }, "0/40/16": { "node_id": 1, diff --git a/tests/components/matter/test_adapter.py b/tests/components/matter/test_adapter.py index 6bd341b0f2f..f83f21dc5e0 100644 --- a/tests/components/matter/test_adapter.py +++ b/tests/components/matter/test_adapter.py @@ -27,10 +27,14 @@ async def test_device_registry_single_node_device( ) dev_reg = dr.async_get(hass) - - entry = dev_reg.async_get_device({(DOMAIN, "mock-onoff-light")}) + entry = dev_reg.async_get_device( + {(DOMAIN, "deviceid_00000000000004D2-0000000000000001-MatterNodeDevice")} + ) assert entry is not None + # test serial id present as additional identifier + assert (DOMAIN, "serial_12345678") in entry.identifiers + assert entry.name == "Mock OnOff Light" assert entry.manufacturer == "Nabu Casa" assert entry.model == "Mock Light" @@ -38,6 +42,30 @@ async def test_device_registry_single_node_device( assert entry.sw_version == "v1.0" +async def test_device_registry_single_node_device_alt( + hass: HomeAssistant, + matter_client: MagicMock, +) -> None: + """Test additional device with different attribute values.""" + await setup_integration_with_node_fixture( + hass, + "on-off-plugin-unit", + matter_client, + ) + + dev_reg = dr.async_get(hass) + entry = dev_reg.async_get_device( + {(DOMAIN, "deviceid_00000000000004D2-0000000000000001-MatterNodeDevice")} + ) + assert entry is not None + + # test name is derived from productName (because nodeLabel is absent) + assert entry.name == "Mock OnOffPluginUnit (powerplug/switch)" + + # test serial id NOT present as additional identifier + assert (DOMAIN, "serial_TEST_SN") not in entry.identifiers + + @pytest.mark.skip("Waiting for a new test fixture") async def test_device_registry_bridge( hass: HomeAssistant, diff --git a/tests/components/matter/test_switch.py b/tests/components/matter/test_switch.py index a79edd6010b..9fe225b1b13 100644 --- a/tests/components/matter/test_switch.py +++ b/tests/components/matter/test_switch.py @@ -30,7 +30,7 @@ async def test_turn_on( switch_node: MatterNode, ) -> None: """Test turning on a switch.""" - state = hass.states.get("switch.mock_onoff_plugin_unit") + state = hass.states.get("switch.mock_onoffpluginunit_powerplug_switch") assert state assert state.state == "off" @@ -38,7 +38,7 @@ async def test_turn_on( "switch", "turn_on", { - "entity_id": "switch.mock_onoff_plugin_unit", + "entity_id": "switch.mock_onoffpluginunit_powerplug_switch", }, blocking=True, ) @@ -53,7 +53,7 @@ async def test_turn_on( set_node_attribute(switch_node, 1, 6, 0, True) await trigger_subscription_callback(hass, matter_client) - state = hass.states.get("switch.mock_onoff_plugin_unit") + state = hass.states.get("switch.mock_onoffpluginunit_powerplug_switch") assert state assert state.state == "on" @@ -64,7 +64,7 @@ async def test_turn_off( switch_node: MatterNode, ) -> None: """Test turning off a switch.""" - state = hass.states.get("switch.mock_onoff_plugin_unit") + state = hass.states.get("switch.mock_onoffpluginunit_powerplug_switch") assert state assert state.state == "off" @@ -72,7 +72,7 @@ async def test_turn_off( "switch", "turn_off", { - "entity_id": "switch.mock_onoff_plugin_unit", + "entity_id": "switch.mock_onoffpluginunit_powerplug_switch", }, blocking=True, )