This commit is contained in:
Paulus Schoutsen 2023-01-19 20:06:01 -05:00 committed by GitHub
commit 9a4329aa1d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 180 additions and 58 deletions

View File

@ -47,6 +47,7 @@ async def verify_redirect_uri(
if client_id == "https://home-assistant.io/android" and redirect_uri in ( if client_id == "https://home-assistant.io/android" and redirect_uri in (
"homeassistant://auth-callback", "homeassistant://auth-callback",
"https://wear.googleapis.com/3p_auth/io.homeassistant.companion.android", "https://wear.googleapis.com/3p_auth/io.homeassistant.companion.android",
"https://wear.googleapis-cn.com/3p_auth/io.homeassistant.companion.android",
): ):
return True return True

View File

@ -73,7 +73,7 @@
"connectable": false "connectable": false
} }
], ],
"requirements": ["govee-ble==0.21.0"], "requirements": ["govee-ble==0.21.1"],
"dependencies": ["bluetooth"], "dependencies": ["bluetooth"],
"codeowners": ["@bdraco", "@PierreAronnax"], "codeowners": ["@bdraco", "@PierreAronnax"],
"iot_class": "local_push" "iot_class": "local_push"

View File

@ -83,6 +83,7 @@ async def _async_send_historical_events(
formatter: Callable[[int, Any], dict[str, Any]], formatter: Callable[[int, Any], dict[str, Any]],
event_processor: EventProcessor, event_processor: EventProcessor,
partial: bool, partial: bool,
force_send: bool = False,
) -> dt | None: ) -> dt | None:
"""Select historical data from the database and deliver it to the websocket. """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 # if its the last one (not partial) so
# consumers of the api know their request was # consumers of the api know their request was
# answered but there were no results # 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) connection.send_message(message)
return last_event_time return last_event_time
@ -150,7 +151,7 @@ async def _async_send_historical_events(
# if its the last one (not partial) so # if its the last one (not partial) so
# consumers of the api know their request was # consumers of the api know their request was
# answered but there were no results # 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) connection.send_message(older_message)
# Returns the time of the newest event # Returns the time of the newest event
@ -384,6 +385,11 @@ async def ws_event_stream(
messages.event_message, messages.event_message,
event_processor, event_processor,
partial=True, 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( live_stream.task = asyncio.create_task(

View File

@ -1,11 +1,15 @@
"""Matter to Home Assistant adapter.""" """Matter to Home Assistant adapter."""
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, cast
from chip.clusters import Objects as all_clusters from chip.clusters import Objects as all_clusters
from matter_server.common.models.events import EventType 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.config_entries import ConfigEntry
from homeassistant.const import Platform 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 import device_registry as dr
from homeassistant.helpers.entity_platform import AddEntitiesCallback 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 .device_platform import DEVICE_PLATFORM
from .helpers import get_device_id
if TYPE_CHECKING: if TYPE_CHECKING:
from matter_server.client import MatterClient from matter_server.client import MatterClient
@ -66,31 +71,56 @@ class MatterAdapter:
bridge_unique_id: str | None = None bridge_unique_id: str | None = None
if node.aggregator_device_type_instance is not None and ( 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( # create virtual (parent) device for bridge node device
node_info, node_info.nodeLabel or "Hub device", None 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: for node_device in node.node_devices:
self._setup_node_device(node_device, bridge_unique_id) self._setup_node_device(node_device, bridge_unique_id)
def _create_device_registry( def _create_device_registry(
self, self,
info: all_clusters.Basic | all_clusters.BridgedDeviceBasic, node_device: AbstractMatterNodeDevice,
name: str, bridge_unique_id: str | None = None,
bridge_unique_id: str | None,
) -> None: ) -> None:
"""Create a device registry entry.""" """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( dr.async_get(self.hass).async_get_or_create(
name=name, name=name,
config_entry_id=self.config_entry.entry_id, config_entry_id=self.config_entry.entry_id,
identifiers={(DOMAIN, info.uniqueID)}, identifiers=identifiers,
hw_version=info.hardwareVersionString, hw_version=basic_info.hardwareVersionString,
sw_version=info.softwareVersionString, sw_version=basic_info.softwareVersionString,
manufacturer=info.vendorName, manufacturer=basic_info.vendorName,
model=info.productName, model=basic_info.productName,
via_device=(DOMAIN, bridge_unique_id) if bridge_unique_id else None, 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 self, node_device: AbstractMatterNodeDevice, bridge_unique_id: str | None
) -> None: ) -> None:
"""Set up a node device.""" """Set up a node device."""
node = node_device.node() self._create_device_registry(node_device, bridge_unique_id)
basic_info = node_device.device_info() # run platform discovery from device type instances
device_type_instances = node_device.device_type_instances() for instance in 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:
created = False created = False
for platform, devices in DEVICE_PLATFORM.items(): for platform, devices in DEVICE_PLATFORM.items():

View File

@ -8,3 +8,7 @@ CONF_USE_ADDON = "use_addon"
DOMAIN = "matter" DOMAIN = "matter"
LOGGER = logging.getLogger(__package__) LOGGER = logging.getLogger(__package__)
# prefixes to identify device identifier id types
ID_TYPE_DEVICE_ID = "deviceid"
ID_TYPE_SERIAL = "serial"

View File

@ -5,16 +5,18 @@ from abc import abstractmethod
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
import logging 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.device_type_instance import MatterDeviceTypeInstance
from matter_server.common.models.events import EventType 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
from matter_server.common.models.server_information import ServerInfo
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription 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: if TYPE_CHECKING:
from matter_server.client import MatterClient from matter_server.client import MatterClient
@ -55,24 +57,21 @@ class MatterEntity(Entity):
self._node_device = node_device self._node_device = node_device
self._device_type_instance = device_type_instance self._device_type_instance = device_type_instance
self.entity_description = entity_description self.entity_description = entity_description
node = device_type_instance.node
self._unsubscribes: list[Callable] = [] self._unsubscribes: list[Callable] = []
# for fast lookups we create a mapping to the attribute paths # 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. # 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 = ( self._attr_unique_id = (
f"{server_info.compressed_fabric_id}-" f"{get_operational_instance_id(server_info, self._node_device.node())}-"
f"{node.unique_id}-"
f"{device_type_instance.endpoint}-" f"{device_type_instance.endpoint}-"
f"{device_type_instance.device_type.device_type}" f"{device_type_instance.device_type.device_type}"
) )
node_device_id = get_device_id(server_info, node_device)
@property self._attr_device_info = DeviceInfo(
def device_info(self) -> DeviceInfo | None: identifiers={(DOMAIN, f"{ID_TYPE_DEVICE_ID}_{node_device_id}")}
"""Return device info for device registry.""" )
return {"identifiers": {(DOMAIN, self._node_device.device_info().uniqueID)}}
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Handle being added to Home Assistant.""" """Handle being added to Home Assistant."""
@ -115,7 +114,7 @@ class MatterEntity(Entity):
@callback @callback
def get_matter_attribute(self, attribute: type) -> MatterAttribute | None: 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( return next(
( (
x x

View File

@ -10,6 +10,10 @@ from homeassistant.core import HomeAssistant, callback
from .const import DOMAIN from .const import DOMAIN
if TYPE_CHECKING: 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 from .adapter import MatterAdapter
@ -25,7 +29,32 @@ class MatterEntryData:
def get_matter(hass: HomeAssistant) -> MatterAdapter: def get_matter(hass: HomeAssistant) -> MatterAdapter:
"""Return MatterAdapter instance.""" """Return MatterAdapter instance."""
# NOTE: This assumes only one Matter connection/fabric can exist. # NOTE: This assumes only one Matter connection/fabric can exist.
# Shall we support connecting to multiple servers in the client or by config entries? # Shall we support connecting to multiple servers in the client or by
# In case of the config entry we need to fix this. # config entries? In case of the config entry we need to fix this.
matter_entry_data: MatterEntryData = next(iter(hass.data[DOMAIN].values())) matter_entry_data: MatterEntryData = next(iter(hass.data[DOMAIN].values()))
return matter_entry_data.adapter 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__}"

View File

@ -475,6 +475,11 @@ class ShellyRpcCoordinator(DataUpdateCoordinator[None]):
async def _async_disconnected(self) -> None: async def _async_disconnected(self) -> None:
"""Handle device disconnected.""" """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: async with self._connection_lock:
if not self.connected: # Already disconnected if not self.connected: # Already disconnected
return return

View File

@ -8,7 +8,7 @@ from .backports.enum import StrEnum
APPLICATION_NAME: Final = "HomeAssistant" APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2023 MAJOR_VERSION: Final = 2023
MINOR_VERSION: Final = 1 MINOR_VERSION: Final = 1
PATCH_VERSION: Final = "5" PATCH_VERSION: Final = "6"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0) REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0)

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "homeassistant" name = "homeassistant"
version = "2023.1.5" version = "2023.1.6"
license = {text = "Apache-2.0"} license = {text = "Apache-2.0"}
description = "Open-source home automation platform running on Python 3." description = "Open-source home automation platform running on Python 3."
readme = "README.rst" readme = "README.rst"

View File

@ -803,7 +803,7 @@ googlemaps==2.5.1
goslide-api==0.5.1 goslide-api==0.5.1
# homeassistant.components.govee_ble # homeassistant.components.govee_ble
govee-ble==0.21.0 govee-ble==0.21.1
# homeassistant.components.remote_rpi_gpio # homeassistant.components.remote_rpi_gpio
gpiozero==1.6.2 gpiozero==1.6.2

View File

@ -607,7 +607,7 @@ google-nest-sdm==2.2.2
googlemaps==2.5.1 googlemaps==2.5.1
# homeassistant.components.govee_ble # homeassistant.components.govee_ble
govee-ble==0.21.0 govee-ble==0.21.1
# homeassistant.components.gree # homeassistant.components.gree
greeclimate==1.3.0 greeclimate==1.3.0

View File

@ -190,9 +190,19 @@ async def test_verify_redirect_uri_android_ios(client_id):
client_id, client_id,
"https://wear.googleapis.com/3p_auth/io.homeassistant.companion.android", "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: else:
assert not await indieauth.verify_redirect_uri( assert not await indieauth.verify_redirect_uri(
None, None,
client_id, client_id,
"https://wear.googleapis.com/3p_auth/io.homeassistant.companion.android", "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",
)

View File

@ -1817,6 +1817,7 @@ async def test_subscribe_unsubscribe_logbook_stream_device(
assert msg["id"] == 7 assert msg["id"] == 7
assert msg["type"] == TYPE_RESULT assert msg["type"] == TYPE_RESULT
assert msg["success"] assert msg["success"]
await async_wait_recording_done(hass)
# There are no answers to our initial query # There are no answers to our initial query
# so we get an empty reply. This is to ensure # 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["id"] == 7
assert msg["type"] == "event" assert msg["type"] == "event"
assert msg["event"]["events"] == [] 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_ON)
hass.states.async_set("binary_sensor.should_not_appear", STATE_OFF) 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["id"] == 7
assert msg["type"] == "event" assert msg["type"] == "event"
assert msg["event"]["events"] == [] 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) 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_ON)

View File

@ -175,7 +175,7 @@
"attribute_id": 5, "attribute_id": 5,
"attribute_type": "chip.clusters.Objects.Basic.Attributes.NodeLabel", "attribute_type": "chip.clusters.Objects.Basic.Attributes.NodeLabel",
"attribute_name": "NodeLabel", "attribute_name": "NodeLabel",
"value": "Mock OnOff Plugin Unit" "value": ""
}, },
"0/40/6": { "0/40/6": {
"node_id": 1, "node_id": 1,

View File

@ -469,7 +469,7 @@
"attribute_id": 15, "attribute_id": 15,
"attribute_type": "chip.clusters.Objects.Basic.Attributes.SerialNumber", "attribute_type": "chip.clusters.Objects.Basic.Attributes.SerialNumber",
"attribute_name": "SerialNumber", "attribute_name": "SerialNumber",
"value": "TEST_SN" "value": "12345678"
}, },
"0/40/16": { "0/40/16": {
"node_id": 1, "node_id": 1,

View File

@ -27,10 +27,14 @@ async def test_device_registry_single_node_device(
) )
dev_reg = dr.async_get(hass) dev_reg = dr.async_get(hass)
entry = dev_reg.async_get_device(
entry = dev_reg.async_get_device({(DOMAIN, "mock-onoff-light")}) {(DOMAIN, "deviceid_00000000000004D2-0000000000000001-MatterNodeDevice")}
)
assert entry is not None 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.name == "Mock OnOff Light"
assert entry.manufacturer == "Nabu Casa" assert entry.manufacturer == "Nabu Casa"
assert entry.model == "Mock Light" assert entry.model == "Mock Light"
@ -38,6 +42,30 @@ async def test_device_registry_single_node_device(
assert entry.sw_version == "v1.0" 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") @pytest.mark.skip("Waiting for a new test fixture")
async def test_device_registry_bridge( async def test_device_registry_bridge(
hass: HomeAssistant, hass: HomeAssistant,

View File

@ -30,7 +30,7 @@ async def test_turn_on(
switch_node: MatterNode, switch_node: MatterNode,
) -> None: ) -> None:
"""Test turning on a switch.""" """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
assert state.state == "off" assert state.state == "off"
@ -38,7 +38,7 @@ async def test_turn_on(
"switch", "switch",
"turn_on", "turn_on",
{ {
"entity_id": "switch.mock_onoff_plugin_unit", "entity_id": "switch.mock_onoffpluginunit_powerplug_switch",
}, },
blocking=True, blocking=True,
) )
@ -53,7 +53,7 @@ async def test_turn_on(
set_node_attribute(switch_node, 1, 6, 0, True) set_node_attribute(switch_node, 1, 6, 0, True)
await trigger_subscription_callback(hass, matter_client) 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
assert state.state == "on" assert state.state == "on"
@ -64,7 +64,7 @@ async def test_turn_off(
switch_node: MatterNode, switch_node: MatterNode,
) -> None: ) -> None:
"""Test turning off a switch.""" """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
assert state.state == "off" assert state.state == "off"
@ -72,7 +72,7 @@ async def test_turn_off(
"switch", "switch",
"turn_off", "turn_off",
{ {
"entity_id": "switch.mock_onoff_plugin_unit", "entity_id": "switch.mock_onoffpluginunit_powerplug_switch",
}, },
blocking=True, blocking=True,
) )