Add matter integration BETA (#83064)
* Add matter base (#79372) Co-authored-by: Marcel van der Veldt <m.vanderveldt@outlook.com> * Add matter server add-on flow (#82698) * Add matter server add-on flow * Fix stale error argument * Clean docstrings * Use localhost as default address * Add matter websocket api foundation (#82848) * Add matter config entry add-on management (#82865) * Use matter refactored server/client library (#83003) Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Bump python-matter-server to 1.0.6 (#83059) * Extend matter websocket api (#82948) * Extend matter websocket api * Finish docstring * Fix pin type * Adjust api after new client * Adjust api to frontend for now Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
parent
1bf5925cdf
commit
095cc77bf9
32 changed files with 4627 additions and 0 deletions
|
@ -724,6 +724,9 @@ omit =
|
||||||
homeassistant/components/map/*
|
homeassistant/components/map/*
|
||||||
homeassistant/components/mastodon/notify.py
|
homeassistant/components/mastodon/notify.py
|
||||||
homeassistant/components/matrix/*
|
homeassistant/components/matrix/*
|
||||||
|
homeassistant/components/matter/__init__.py
|
||||||
|
homeassistant/components/matter/adapter.py
|
||||||
|
homeassistant/components/matter/entity.py
|
||||||
homeassistant/components/meater/__init__.py
|
homeassistant/components/meater/__init__.py
|
||||||
homeassistant/components/meater/const.py
|
homeassistant/components/meater/const.py
|
||||||
homeassistant/components/meater/sensor.py
|
homeassistant/components/meater/sensor.py
|
||||||
|
|
|
@ -179,6 +179,7 @@ homeassistant.components.logger.*
|
||||||
homeassistant.components.lookin.*
|
homeassistant.components.lookin.*
|
||||||
homeassistant.components.luftdaten.*
|
homeassistant.components.luftdaten.*
|
||||||
homeassistant.components.mailbox.*
|
homeassistant.components.mailbox.*
|
||||||
|
homeassistant.components.matter.*
|
||||||
homeassistant.components.media_player.*
|
homeassistant.components.media_player.*
|
||||||
homeassistant.components.media_source.*
|
homeassistant.components.media_source.*
|
||||||
homeassistant.components.metoffice.*
|
homeassistant.components.metoffice.*
|
||||||
|
|
|
@ -666,6 +666,8 @@ build.json @home-assistant/supervisor
|
||||||
/tests/components/lyric/ @timmo001
|
/tests/components/lyric/ @timmo001
|
||||||
/homeassistant/components/mastodon/ @fabaff
|
/homeassistant/components/mastodon/ @fabaff
|
||||||
/homeassistant/components/matrix/ @tinloaf
|
/homeassistant/components/matrix/ @tinloaf
|
||||||
|
/homeassistant/components/matter/ @MartinHjelmare @marcelveldt
|
||||||
|
/tests/components/matter/ @MartinHjelmare @marcelveldt
|
||||||
/homeassistant/components/mazda/ @bdr99
|
/homeassistant/components/mazda/ @bdr99
|
||||||
/tests/components/mazda/ @bdr99
|
/tests/components/mazda/ @bdr99
|
||||||
/homeassistant/components/meater/ @Sotolotl @emontnemery
|
/homeassistant/components/meater/ @Sotolotl @emontnemery
|
||||||
|
|
351
homeassistant/components/matter/__init__.py
Normal file
351
homeassistant/components/matter/__init__.py
Normal file
|
@ -0,0 +1,351 @@
|
||||||
|
"""The Matter integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
import async_timeout
|
||||||
|
from matter_server.client import MatterClient
|
||||||
|
from matter_server.client.exceptions import (
|
||||||
|
CannotConnect,
|
||||||
|
FailedCommand,
|
||||||
|
InvalidServerVersion,
|
||||||
|
)
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.components.hassio import AddonError, AddonManager, AddonState
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP
|
||||||
|
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
|
||||||
|
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
|
||||||
|
from homeassistant.helpers import device_registry as dr
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
from homeassistant.helpers.issue_registry import (
|
||||||
|
IssueSeverity,
|
||||||
|
async_create_issue,
|
||||||
|
async_delete_issue,
|
||||||
|
)
|
||||||
|
from homeassistant.helpers.service import async_register_admin_service
|
||||||
|
|
||||||
|
from .adapter import MatterAdapter
|
||||||
|
from .addon import get_addon_manager
|
||||||
|
from .api import async_register_api
|
||||||
|
from .const import CONF_INTEGRATION_CREATED_ADDON, CONF_USE_ADDON, DOMAIN, LOGGER
|
||||||
|
from .device_platform import DEVICE_PLATFORM
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Set up Matter from a config entry."""
|
||||||
|
if use_addon := entry.data.get(CONF_USE_ADDON):
|
||||||
|
await _async_ensure_addon_running(hass, entry)
|
||||||
|
|
||||||
|
matter_client = MatterClient(entry.data[CONF_URL], async_get_clientsession(hass))
|
||||||
|
try:
|
||||||
|
await matter_client.connect()
|
||||||
|
except CannotConnect as err:
|
||||||
|
raise ConfigEntryNotReady("Failed to connect to matter server") from err
|
||||||
|
except InvalidServerVersion as err:
|
||||||
|
if use_addon:
|
||||||
|
addon_manager = _get_addon_manager(hass)
|
||||||
|
addon_manager.async_schedule_update_addon(catch_error=True)
|
||||||
|
else:
|
||||||
|
async_create_issue(
|
||||||
|
hass,
|
||||||
|
DOMAIN,
|
||||||
|
"invalid_server_version",
|
||||||
|
is_fixable=False,
|
||||||
|
severity=IssueSeverity.ERROR,
|
||||||
|
translation_key="invalid_server_version",
|
||||||
|
)
|
||||||
|
raise ConfigEntryNotReady(f"Invalid server version: {err}") from err
|
||||||
|
|
||||||
|
except Exception as err:
|
||||||
|
matter_client.logger.exception("Failed to connect to matter server")
|
||||||
|
raise ConfigEntryNotReady(
|
||||||
|
"Unknown error connecting to the Matter server"
|
||||||
|
) from err
|
||||||
|
else:
|
||||||
|
async_delete_issue(hass, DOMAIN, "invalid_server_version")
|
||||||
|
|
||||||
|
async def on_hass_stop(event: Event) -> None:
|
||||||
|
"""Handle incoming stop event from Home Assistant."""
|
||||||
|
await matter_client.disconnect()
|
||||||
|
|
||||||
|
entry.async_on_unload(
|
||||||
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop)
|
||||||
|
)
|
||||||
|
|
||||||
|
# register websocket api
|
||||||
|
async_register_api(hass)
|
||||||
|
|
||||||
|
# launch the matter client listen task in the background
|
||||||
|
# use the init_ready event to keep track if it did initialize successfully
|
||||||
|
init_ready = asyncio.Event()
|
||||||
|
listen_task = asyncio.create_task(matter_client.start_listening(init_ready))
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with async_timeout.timeout(30):
|
||||||
|
await init_ready.wait()
|
||||||
|
except asyncio.TimeoutError as err:
|
||||||
|
listen_task.cancel()
|
||||||
|
raise ConfigEntryNotReady("Matter client not ready") from err
|
||||||
|
|
||||||
|
if DOMAIN not in hass.data:
|
||||||
|
hass.data[DOMAIN] = {}
|
||||||
|
_async_init_services(hass)
|
||||||
|
|
||||||
|
# we create an intermediate layer (adapter) which keeps track of our nodes
|
||||||
|
# and discovery of platform entities from the node's attributes
|
||||||
|
matter = MatterAdapter(hass, matter_client, entry)
|
||||||
|
hass.data[DOMAIN][entry.entry_id] = matter
|
||||||
|
|
||||||
|
# forward platform setup to all platforms in the discovery schema
|
||||||
|
await hass.config_entries.async_forward_entry_setups(entry, DEVICE_PLATFORM)
|
||||||
|
|
||||||
|
# start discovering of node entities as task
|
||||||
|
asyncio.create_task(matter.setup_nodes())
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Unload a config entry."""
|
||||||
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, DEVICE_PLATFORM)
|
||||||
|
|
||||||
|
if unload_ok:
|
||||||
|
matter: MatterAdapter = hass.data[DOMAIN].pop(entry.entry_id)
|
||||||
|
await matter.matter_client.disconnect()
|
||||||
|
|
||||||
|
if entry.data.get(CONF_USE_ADDON) and entry.disabled_by:
|
||||||
|
addon_manager: AddonManager = get_addon_manager(hass)
|
||||||
|
LOGGER.debug("Stopping Matter Server add-on")
|
||||||
|
try:
|
||||||
|
await addon_manager.async_stop_addon()
|
||||||
|
except AddonError as err:
|
||||||
|
LOGGER.error("Failed to stop the Matter Server add-on: %s", err)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return unload_ok
|
||||||
|
|
||||||
|
|
||||||
|
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
|
"""Config entry is being removed."""
|
||||||
|
|
||||||
|
if not entry.data.get(CONF_INTEGRATION_CREATED_ADDON):
|
||||||
|
return
|
||||||
|
|
||||||
|
addon_manager: AddonManager = get_addon_manager(hass)
|
||||||
|
try:
|
||||||
|
await addon_manager.async_stop_addon()
|
||||||
|
except AddonError as err:
|
||||||
|
LOGGER.error(err)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await addon_manager.async_create_backup()
|
||||||
|
except AddonError as err:
|
||||||
|
LOGGER.error(err)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await addon_manager.async_uninstall_addon()
|
||||||
|
except AddonError as err:
|
||||||
|
LOGGER.error(err)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_remove_config_entry_device(
|
||||||
|
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry
|
||||||
|
) -> bool:
|
||||||
|
"""Remove a config entry from a device."""
|
||||||
|
unique_id = None
|
||||||
|
|
||||||
|
for ident in device_entry.identifiers:
|
||||||
|
if ident[0] == DOMAIN:
|
||||||
|
unique_id = ident[1]
|
||||||
|
break
|
||||||
|
|
||||||
|
if not unique_id:
|
||||||
|
return True
|
||||||
|
|
||||||
|
matter: MatterAdapter = hass.data[DOMAIN][config_entry.entry_id]
|
||||||
|
|
||||||
|
for node in await matter.matter_client.get_nodes():
|
||||||
|
if node.unique_id == unique_id:
|
||||||
|
await matter.matter_client.remove_node(node.node_id)
|
||||||
|
break
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
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.
|
||||||
|
matter: MatterAdapter = next(iter(hass.data[DOMAIN].values()))
|
||||||
|
return matter
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_init_services(hass: HomeAssistant) -> None:
|
||||||
|
"""Init services."""
|
||||||
|
|
||||||
|
async def commission(call: ServiceCall) -> None:
|
||||||
|
"""Handle commissioning."""
|
||||||
|
matter_client = get_matter(hass).matter_client
|
||||||
|
try:
|
||||||
|
await matter_client.commission_with_code(call.data["code"])
|
||||||
|
except FailedCommand as err:
|
||||||
|
raise HomeAssistantError(str(err)) from err
|
||||||
|
|
||||||
|
async_register_admin_service(
|
||||||
|
hass,
|
||||||
|
DOMAIN,
|
||||||
|
"commission",
|
||||||
|
commission,
|
||||||
|
vol.Schema({"code": str}),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def accept_shared_device(call: ServiceCall) -> None:
|
||||||
|
"""Accept a shared device."""
|
||||||
|
matter_client = get_matter(hass).matter_client
|
||||||
|
try:
|
||||||
|
await matter_client.commission_on_network(call.data["pin"])
|
||||||
|
except FailedCommand as err:
|
||||||
|
raise HomeAssistantError(str(err)) from err
|
||||||
|
|
||||||
|
async_register_admin_service(
|
||||||
|
hass,
|
||||||
|
DOMAIN,
|
||||||
|
"accept_shared_device",
|
||||||
|
accept_shared_device,
|
||||||
|
vol.Schema({"pin": vol.Coerce(int)}),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_wifi(call: ServiceCall) -> None:
|
||||||
|
"""Handle set wifi creds."""
|
||||||
|
matter_client = get_matter(hass).matter_client
|
||||||
|
try:
|
||||||
|
await matter_client.set_wifi_credentials(
|
||||||
|
call.data["ssid"], call.data["password"]
|
||||||
|
)
|
||||||
|
except FailedCommand as err:
|
||||||
|
raise HomeAssistantError(str(err)) from err
|
||||||
|
|
||||||
|
async_register_admin_service(
|
||||||
|
hass,
|
||||||
|
DOMAIN,
|
||||||
|
"set_wifi",
|
||||||
|
set_wifi,
|
||||||
|
vol.Schema(
|
||||||
|
{
|
||||||
|
"ssid": str,
|
||||||
|
"password": str,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_thread_dataset(call: ServiceCall) -> None:
|
||||||
|
"""Handle set Thread creds."""
|
||||||
|
matter_client = get_matter(hass).matter_client
|
||||||
|
thread_dataset = bytes.fromhex(call.data["dataset"])
|
||||||
|
try:
|
||||||
|
await matter_client.set_thread_operational_dataset(thread_dataset)
|
||||||
|
except FailedCommand as err:
|
||||||
|
raise HomeAssistantError(str(err)) from err
|
||||||
|
|
||||||
|
async_register_admin_service(
|
||||||
|
hass,
|
||||||
|
DOMAIN,
|
||||||
|
"set_thread",
|
||||||
|
set_thread_dataset,
|
||||||
|
vol.Schema({"dataset": str}),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _node_id_from_ha_device_id(ha_device_id: str) -> int | None:
|
||||||
|
"""Get node id from ha device id."""
|
||||||
|
dev_reg = dr.async_get(hass)
|
||||||
|
device = dev_reg.async_get(ha_device_id)
|
||||||
|
|
||||||
|
if device is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
matter_id = next(
|
||||||
|
(
|
||||||
|
identifier
|
||||||
|
for identifier in device.identifiers
|
||||||
|
if identifier[0] == DOMAIN
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not matter_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
unique_id = matter_id[1]
|
||||||
|
|
||||||
|
matter_client = get_matter(hass).matter_client
|
||||||
|
|
||||||
|
# This could be more efficient
|
||||||
|
for node in await matter_client.get_nodes():
|
||||||
|
if node.unique_id == unique_id:
|
||||||
|
return cast(int, node.node_id)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def open_commissioning_window(call: ServiceCall) -> None:
|
||||||
|
"""Open commissioning window on specific node."""
|
||||||
|
node_id = await _node_id_from_ha_device_id(call.data["device_id"])
|
||||||
|
|
||||||
|
if node_id is None:
|
||||||
|
raise HomeAssistantError("This is not a Matter device")
|
||||||
|
|
||||||
|
matter_client = get_matter(hass).matter_client
|
||||||
|
|
||||||
|
# We are sending device ID .
|
||||||
|
|
||||||
|
try:
|
||||||
|
await matter_client.open_commissioning_window(node_id)
|
||||||
|
except FailedCommand as err:
|
||||||
|
raise HomeAssistantError(str(err)) from err
|
||||||
|
|
||||||
|
async_register_admin_service(
|
||||||
|
hass,
|
||||||
|
DOMAIN,
|
||||||
|
"open_commissioning_window",
|
||||||
|
open_commissioning_window,
|
||||||
|
vol.Schema({"device_id": str}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
|
"""Ensure that Matter Server add-on is installed and running."""
|
||||||
|
addon_manager = _get_addon_manager(hass)
|
||||||
|
try:
|
||||||
|
addon_info = await addon_manager.async_get_addon_info()
|
||||||
|
except AddonError as err:
|
||||||
|
raise ConfigEntryNotReady(err) from err
|
||||||
|
|
||||||
|
addon_state = addon_info.state
|
||||||
|
|
||||||
|
if addon_state == AddonState.NOT_INSTALLED:
|
||||||
|
addon_manager.async_schedule_install_setup_addon(
|
||||||
|
addon_info.options,
|
||||||
|
catch_error=True,
|
||||||
|
)
|
||||||
|
raise ConfigEntryNotReady
|
||||||
|
|
||||||
|
if addon_state == AddonState.NOT_RUNNING:
|
||||||
|
addon_manager.async_schedule_start_addon(catch_error=True)
|
||||||
|
raise ConfigEntryNotReady
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _get_addon_manager(hass: HomeAssistant) -> AddonManager:
|
||||||
|
"""Ensure that Matter Server add-on is updated and running.
|
||||||
|
|
||||||
|
May only be used as part of async_setup_entry above.
|
||||||
|
"""
|
||||||
|
addon_manager: AddonManager = get_addon_manager(hass)
|
||||||
|
if addon_manager.task_in_progress():
|
||||||
|
raise ConfigEntryNotReady
|
||||||
|
return addon_manager
|
141
homeassistant/components/matter/adapter.py
Normal file
141
homeassistant/components/matter/adapter.py
Normal file
|
@ -0,0 +1,141 @@
|
||||||
|
"""Matter to Home Assistant adapter."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from chip.clusters import Objects as all_clusters
|
||||||
|
from matter_server.common.models.node_device import AbstractMatterNodeDevice
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import Platform
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import device_registry as dr
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .device_platform import DEVICE_PLATFORM
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from matter_server.client import MatterClient
|
||||||
|
from matter_server.common.models.node import MatterNode
|
||||||
|
|
||||||
|
|
||||||
|
class MatterAdapter:
|
||||||
|
"""Connect Matter into Home Assistant."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
matter_client: MatterClient,
|
||||||
|
config_entry: ConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the adapter."""
|
||||||
|
self.matter_client = matter_client
|
||||||
|
self.hass = hass
|
||||||
|
self.config_entry = config_entry
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
self.platform_handlers: dict[Platform, AddEntitiesCallback] = {}
|
||||||
|
self._platforms_set_up = asyncio.Event()
|
||||||
|
|
||||||
|
def register_platform_handler(
|
||||||
|
self, platform: Platform, add_entities: AddEntitiesCallback
|
||||||
|
) -> None:
|
||||||
|
"""Register a platform handler."""
|
||||||
|
self.platform_handlers[platform] = add_entities
|
||||||
|
if len(self.platform_handlers) == len(DEVICE_PLATFORM):
|
||||||
|
self._platforms_set_up.set()
|
||||||
|
|
||||||
|
async def setup_nodes(self) -> None:
|
||||||
|
"""Set up all existing nodes."""
|
||||||
|
await self._platforms_set_up.wait()
|
||||||
|
for node in await self.matter_client.get_nodes():
|
||||||
|
await self._setup_node(node)
|
||||||
|
|
||||||
|
async def _setup_node(self, node: MatterNode) -> None:
|
||||||
|
"""Set up an node."""
|
||||||
|
self.logger.debug("Setting up entities for node %s", node.node_id)
|
||||||
|
|
||||||
|
bridge_unique_id: str | None = None
|
||||||
|
|
||||||
|
if node.aggregator_device_type_instance is not None:
|
||||||
|
node_info = node.root_device_type_instance.get_cluster(all_clusters.Basic)
|
||||||
|
self._create_device_registry(
|
||||||
|
node_info, node_info.nodeLabel or "Hub device", None
|
||||||
|
)
|
||||||
|
bridge_unique_id = node_info.uniqueID
|
||||||
|
|
||||||
|
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,
|
||||||
|
) -> None:
|
||||||
|
"""Create a device registry entry."""
|
||||||
|
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,
|
||||||
|
via_device=(DOMAIN, bridge_unique_id) if bridge_unique_id else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _setup_node_device(
|
||||||
|
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:
|
||||||
|
created = False
|
||||||
|
|
||||||
|
for platform, devices in DEVICE_PLATFORM.items():
|
||||||
|
entity_descriptions = devices.get(instance.device_type)
|
||||||
|
|
||||||
|
if entity_descriptions is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not isinstance(entity_descriptions, list):
|
||||||
|
entity_descriptions = [entity_descriptions]
|
||||||
|
|
||||||
|
entities = []
|
||||||
|
for entity_description in entity_descriptions:
|
||||||
|
self.logger.debug(
|
||||||
|
"Creating %s entity for %s (%s)",
|
||||||
|
platform,
|
||||||
|
instance.device_type.__name__,
|
||||||
|
hex(instance.device_type.device_type),
|
||||||
|
)
|
||||||
|
entities.append(
|
||||||
|
entity_description.entity_cls(
|
||||||
|
self.matter_client,
|
||||||
|
node_device,
|
||||||
|
instance,
|
||||||
|
entity_description,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.platform_handlers[platform](entities)
|
||||||
|
created = True
|
||||||
|
|
||||||
|
if not created:
|
||||||
|
self.logger.warning(
|
||||||
|
"Found unsupported device %s (%s)",
|
||||||
|
type(instance).__name__,
|
||||||
|
hex(instance.device_type.device_type),
|
||||||
|
)
|
17
homeassistant/components/matter/addon.py
Normal file
17
homeassistant/components/matter/addon.py
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
"""Provide add-on management."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.components.hassio import AddonManager
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers.singleton import singleton
|
||||||
|
|
||||||
|
from .const import ADDON_SLUG, DOMAIN, LOGGER
|
||||||
|
|
||||||
|
DATA_ADDON_MANAGER = f"{DOMAIN}_addon_manager"
|
||||||
|
|
||||||
|
|
||||||
|
@singleton(DATA_ADDON_MANAGER)
|
||||||
|
@callback
|
||||||
|
def get_addon_manager(hass: HomeAssistant) -> AddonManager:
|
||||||
|
"""Get the add-on manager."""
|
||||||
|
return AddonManager(hass, LOGGER, "Matter Server", ADDON_SLUG)
|
152
homeassistant/components/matter/api.py
Normal file
152
homeassistant/components/matter/api.py
Normal file
|
@ -0,0 +1,152 @@
|
||||||
|
"""Handle websocket api for Matter."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from functools import wraps
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from matter_server.client.exceptions import FailedCommand
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.components import websocket_api
|
||||||
|
from homeassistant.components.websocket_api import ActiveConnection
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
|
||||||
|
from .adapter import MatterAdapter
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
ID = "id"
|
||||||
|
TYPE = "type"
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_register_api(hass: HomeAssistant) -> None:
|
||||||
|
"""Register all of our api endpoints."""
|
||||||
|
websocket_api.async_register_command(hass, websocket_commission)
|
||||||
|
websocket_api.async_register_command(hass, websocket_commission_on_network)
|
||||||
|
websocket_api.async_register_command(hass, websocket_set_thread_dataset)
|
||||||
|
websocket_api.async_register_command(hass, websocket_set_wifi_credentials)
|
||||||
|
|
||||||
|
|
||||||
|
def async_get_matter_adapter(func: Callable) -> Callable:
|
||||||
|
"""Decorate function to get the MatterAdapter."""
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
async def _get_matter(
|
||||||
|
hass: HomeAssistant, connection: ActiveConnection, msg: dict
|
||||||
|
) -> None:
|
||||||
|
"""Provide the Matter client to the function."""
|
||||||
|
matter: MatterAdapter = next(iter(hass.data[DOMAIN].values()))
|
||||||
|
|
||||||
|
await func(hass, connection, msg, matter)
|
||||||
|
|
||||||
|
return _get_matter
|
||||||
|
|
||||||
|
|
||||||
|
def async_handle_failed_command(func: Callable) -> Callable:
|
||||||
|
"""Decorate function to handle FailedCommand and send relevant error."""
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
async def async_handle_failed_command_func(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
connection: ActiveConnection,
|
||||||
|
msg: dict[str, Any],
|
||||||
|
*args: Any,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> None:
|
||||||
|
"""Handle FailedCommand within function and send relevant error."""
|
||||||
|
try:
|
||||||
|
await func(hass, connection, msg, *args, **kwargs)
|
||||||
|
except FailedCommand as err:
|
||||||
|
connection.send_error(msg[ID], err.error_code, err.args[0])
|
||||||
|
|
||||||
|
return async_handle_failed_command_func
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.require_admin
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required(TYPE): "matter/commission",
|
||||||
|
vol.Required("code"): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@websocket_api.async_response
|
||||||
|
@async_handle_failed_command
|
||||||
|
@async_get_matter_adapter
|
||||||
|
async def websocket_commission(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
connection: ActiveConnection,
|
||||||
|
msg: dict[str, Any],
|
||||||
|
matter: MatterAdapter,
|
||||||
|
) -> None:
|
||||||
|
"""Add a device to the network and commission the device."""
|
||||||
|
await matter.matter_client.commission_with_code(msg["code"])
|
||||||
|
connection.send_result(msg[ID])
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.require_admin
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required(TYPE): "matter/commission_on_network",
|
||||||
|
vol.Required("pin"): int,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@websocket_api.async_response
|
||||||
|
@async_handle_failed_command
|
||||||
|
@async_get_matter_adapter
|
||||||
|
async def websocket_commission_on_network(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
connection: ActiveConnection,
|
||||||
|
msg: dict[str, Any],
|
||||||
|
matter: MatterAdapter,
|
||||||
|
) -> None:
|
||||||
|
"""Commission a device already on the network."""
|
||||||
|
await matter.matter_client.commission_on_network(msg["pin"])
|
||||||
|
connection.send_result(msg[ID])
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.require_admin
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required(TYPE): "matter/set_thread",
|
||||||
|
vol.Required("thread_operation_dataset"): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@websocket_api.async_response
|
||||||
|
@async_handle_failed_command
|
||||||
|
@async_get_matter_adapter
|
||||||
|
async def websocket_set_thread_dataset(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
connection: ActiveConnection,
|
||||||
|
msg: dict[str, Any],
|
||||||
|
matter: MatterAdapter,
|
||||||
|
) -> None:
|
||||||
|
"""Set thread dataset."""
|
||||||
|
await matter.matter_client.set_thread_operational_dataset(
|
||||||
|
msg["thread_operation_dataset"]
|
||||||
|
)
|
||||||
|
connection.send_result(msg[ID])
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.require_admin
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required(TYPE): "matter/set_wifi_credentials",
|
||||||
|
vol.Required("network_name"): str,
|
||||||
|
vol.Required("password"): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@websocket_api.async_response
|
||||||
|
@async_handle_failed_command
|
||||||
|
@async_get_matter_adapter
|
||||||
|
async def websocket_set_wifi_credentials(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
connection: ActiveConnection,
|
||||||
|
msg: dict[str, Any],
|
||||||
|
matter: MatterAdapter,
|
||||||
|
) -> None:
|
||||||
|
"""Set WiFi credentials for a device."""
|
||||||
|
await matter.matter_client.set_wifi_credentials(
|
||||||
|
ssid=msg["network_name"], credentials=msg["password"]
|
||||||
|
)
|
||||||
|
connection.send_result(msg[ID])
|
325
homeassistant/components/matter/config_flow.py
Normal file
325
homeassistant/components/matter/config_flow.py
Normal file
|
@ -0,0 +1,325 @@
|
||||||
|
"""Config flow for Matter integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from matter_server.client import MatterClient
|
||||||
|
from matter_server.client.exceptions import CannotConnect, InvalidServerVersion
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.components.hassio import (
|
||||||
|
AddonError,
|
||||||
|
AddonInfo,
|
||||||
|
AddonManager,
|
||||||
|
AddonState,
|
||||||
|
HassioServiceInfo,
|
||||||
|
is_hassio,
|
||||||
|
)
|
||||||
|
from homeassistant.const import CONF_URL
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.data_entry_flow import AbortFlow, FlowResult
|
||||||
|
from homeassistant.helpers import aiohttp_client
|
||||||
|
|
||||||
|
from .addon import get_addon_manager
|
||||||
|
from .const import (
|
||||||
|
ADDON_SLUG,
|
||||||
|
CONF_INTEGRATION_CREATED_ADDON,
|
||||||
|
CONF_USE_ADDON,
|
||||||
|
DOMAIN,
|
||||||
|
LOGGER,
|
||||||
|
)
|
||||||
|
|
||||||
|
ADDON_SETUP_TIMEOUT = 5
|
||||||
|
ADDON_SETUP_TIMEOUT_ROUNDS = 40
|
||||||
|
DEFAULT_URL = "ws://localhost:5580/ws"
|
||||||
|
ON_SUPERVISOR_SCHEMA = vol.Schema({vol.Optional(CONF_USE_ADDON, default=True): bool})
|
||||||
|
|
||||||
|
|
||||||
|
def get_manual_schema(user_input: dict[str, Any]) -> vol.Schema:
|
||||||
|
"""Return a schema for the manual step."""
|
||||||
|
default_url = user_input.get(CONF_URL, DEFAULT_URL)
|
||||||
|
return vol.Schema({vol.Required(CONF_URL, default=default_url): str})
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
|
||||||
|
"""Validate the user input allows us to connect."""
|
||||||
|
client = MatterClient(data[CONF_URL], aiohttp_client.async_get_clientsession(hass))
|
||||||
|
await client.connect()
|
||||||
|
|
||||||
|
|
||||||
|
def build_ws_address(host: str, port: int) -> str:
|
||||||
|
"""Return the websocket address."""
|
||||||
|
return f"ws://{host}:{port}/ws"
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for Matter."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Set up flow instance."""
|
||||||
|
self.ws_address: str | None = None
|
||||||
|
# If we install the add-on we should uninstall it on entry remove.
|
||||||
|
self.integration_created_addon = False
|
||||||
|
self.install_task: asyncio.Task | None = None
|
||||||
|
self.start_task: asyncio.Task | None = None
|
||||||
|
self.use_addon = False
|
||||||
|
|
||||||
|
async def async_step_install_addon(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Install Matter Server add-on."""
|
||||||
|
if not self.install_task:
|
||||||
|
self.install_task = self.hass.async_create_task(self._async_install_addon())
|
||||||
|
return self.async_show_progress(
|
||||||
|
step_id="install_addon", progress_action="install_addon"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.install_task
|
||||||
|
except AddonError as err:
|
||||||
|
self.install_task = None
|
||||||
|
LOGGER.error(err)
|
||||||
|
return self.async_show_progress_done(next_step_id="install_failed")
|
||||||
|
|
||||||
|
self.integration_created_addon = True
|
||||||
|
self.install_task = None
|
||||||
|
|
||||||
|
return self.async_show_progress_done(next_step_id="start_addon")
|
||||||
|
|
||||||
|
async def async_step_install_failed(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Add-on installation failed."""
|
||||||
|
return self.async_abort(reason="addon_install_failed")
|
||||||
|
|
||||||
|
async def _async_install_addon(self) -> None:
|
||||||
|
"""Install the Matter Server add-on."""
|
||||||
|
addon_manager: AddonManager = get_addon_manager(self.hass)
|
||||||
|
try:
|
||||||
|
await addon_manager.async_schedule_install_addon()
|
||||||
|
finally:
|
||||||
|
# Continue the flow after show progress when the task is done.
|
||||||
|
self.hass.async_create_task(
|
||||||
|
self.hass.config_entries.flow.async_configure(flow_id=self.flow_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _async_get_addon_discovery_info(self) -> dict:
|
||||||
|
"""Return add-on discovery info."""
|
||||||
|
addon_manager: AddonManager = get_addon_manager(self.hass)
|
||||||
|
try:
|
||||||
|
discovery_info_config = await addon_manager.async_get_addon_discovery_info()
|
||||||
|
except AddonError as err:
|
||||||
|
LOGGER.error(err)
|
||||||
|
raise AbortFlow("addon_get_discovery_info_failed") from err
|
||||||
|
|
||||||
|
return discovery_info_config
|
||||||
|
|
||||||
|
async def async_step_start_addon(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Start Matter Server add-on."""
|
||||||
|
if not self.start_task:
|
||||||
|
self.start_task = self.hass.async_create_task(self._async_start_addon())
|
||||||
|
return self.async_show_progress(
|
||||||
|
step_id="start_addon", progress_action="start_addon"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.start_task
|
||||||
|
except (CannotConnect, AddonError, AbortFlow) as err:
|
||||||
|
self.start_task = None
|
||||||
|
LOGGER.error(err)
|
||||||
|
return self.async_show_progress_done(next_step_id="start_failed")
|
||||||
|
|
||||||
|
self.start_task = None
|
||||||
|
return self.async_show_progress_done(next_step_id="finish_addon_setup")
|
||||||
|
|
||||||
|
async def async_step_start_failed(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Add-on start failed."""
|
||||||
|
return self.async_abort(reason="addon_start_failed")
|
||||||
|
|
||||||
|
async def _async_start_addon(self) -> None:
|
||||||
|
"""Start the Matter Server add-on."""
|
||||||
|
addon_manager: AddonManager = get_addon_manager(self.hass)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await addon_manager.async_schedule_start_addon()
|
||||||
|
# Sleep some seconds to let the add-on start properly before connecting.
|
||||||
|
for _ in range(ADDON_SETUP_TIMEOUT_ROUNDS):
|
||||||
|
await asyncio.sleep(ADDON_SETUP_TIMEOUT)
|
||||||
|
try:
|
||||||
|
if not (ws_address := self.ws_address):
|
||||||
|
discovery_info = await self._async_get_addon_discovery_info()
|
||||||
|
ws_address = self.ws_address = build_ws_address(
|
||||||
|
discovery_info["host"], discovery_info["port"]
|
||||||
|
)
|
||||||
|
await validate_input(self.hass, {CONF_URL: ws_address})
|
||||||
|
except (AbortFlow, CannotConnect) as err:
|
||||||
|
LOGGER.debug(
|
||||||
|
"Add-on not ready yet, waiting %s seconds: %s",
|
||||||
|
ADDON_SETUP_TIMEOUT,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise CannotConnect("Failed to start Matter Server add-on: timeout")
|
||||||
|
finally:
|
||||||
|
# Continue the flow after show progress when the task is done.
|
||||||
|
self.hass.async_create_task(
|
||||||
|
self.hass.config_entries.flow.async_configure(flow_id=self.flow_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _async_get_addon_info(self) -> AddonInfo:
|
||||||
|
"""Return Matter Server add-on info."""
|
||||||
|
addon_manager: AddonManager = get_addon_manager(self.hass)
|
||||||
|
try:
|
||||||
|
addon_info: AddonInfo = await addon_manager.async_get_addon_info()
|
||||||
|
except AddonError as err:
|
||||||
|
LOGGER.error(err)
|
||||||
|
raise AbortFlow("addon_info_failed") from err
|
||||||
|
|
||||||
|
return addon_info
|
||||||
|
|
||||||
|
async def async_step_user(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Handle the initial step."""
|
||||||
|
if is_hassio(self.hass):
|
||||||
|
return await self.async_step_on_supervisor()
|
||||||
|
|
||||||
|
return await self.async_step_manual()
|
||||||
|
|
||||||
|
async def async_step_manual(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Handle a manual configuration."""
|
||||||
|
if user_input is None:
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="manual", data_schema=get_manual_schema({})
|
||||||
|
)
|
||||||
|
|
||||||
|
errors = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
await validate_input(self.hass, user_input)
|
||||||
|
except CannotConnect:
|
||||||
|
errors["base"] = "cannot_connect"
|
||||||
|
except InvalidServerVersion:
|
||||||
|
errors["base"] = "invalid_server_version"
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
LOGGER.exception("Unexpected exception")
|
||||||
|
errors["base"] = "unknown"
|
||||||
|
else:
|
||||||
|
self.ws_address = user_input[CONF_URL]
|
||||||
|
|
||||||
|
return await self._async_create_entry_or_abort()
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="manual", data_schema=get_manual_schema(user_input), errors=errors
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResult:
|
||||||
|
"""Receive configuration from add-on discovery info.
|
||||||
|
|
||||||
|
This flow is triggered by the Matter Server add-on.
|
||||||
|
"""
|
||||||
|
if discovery_info.slug != ADDON_SLUG:
|
||||||
|
return self.async_abort(reason="not_matter_addon")
|
||||||
|
|
||||||
|
await self._async_handle_discovery_without_unique_id()
|
||||||
|
|
||||||
|
self.ws_address = build_ws_address(
|
||||||
|
discovery_info.config["host"], discovery_info.config["port"]
|
||||||
|
)
|
||||||
|
|
||||||
|
return await self.async_step_hassio_confirm()
|
||||||
|
|
||||||
|
async def async_step_hassio_confirm(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Confirm the add-on discovery."""
|
||||||
|
if user_input is not None:
|
||||||
|
return await self.async_step_on_supervisor(
|
||||||
|
user_input={CONF_USE_ADDON: True}
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.async_show_form(step_id="hassio_confirm")
|
||||||
|
|
||||||
|
async def async_step_on_supervisor(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Handle logic when on Supervisor host."""
|
||||||
|
if user_input is None:
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="on_supervisor", data_schema=ON_SUPERVISOR_SCHEMA
|
||||||
|
)
|
||||||
|
if not user_input[CONF_USE_ADDON]:
|
||||||
|
return await self.async_step_manual()
|
||||||
|
|
||||||
|
self.use_addon = True
|
||||||
|
|
||||||
|
addon_info = await self._async_get_addon_info()
|
||||||
|
|
||||||
|
if addon_info.state == AddonState.RUNNING:
|
||||||
|
return await self.async_step_finish_addon_setup()
|
||||||
|
|
||||||
|
if addon_info.state == AddonState.NOT_RUNNING:
|
||||||
|
return await self.async_step_start_addon()
|
||||||
|
|
||||||
|
return await self.async_step_install_addon()
|
||||||
|
|
||||||
|
async def async_step_finish_addon_setup(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Prepare info needed to complete the config entry."""
|
||||||
|
if not self.ws_address:
|
||||||
|
discovery_info = await self._async_get_addon_discovery_info()
|
||||||
|
ws_address = self.ws_address = build_ws_address(
|
||||||
|
discovery_info["host"], discovery_info["port"]
|
||||||
|
)
|
||||||
|
# Check that we can connect to the address.
|
||||||
|
try:
|
||||||
|
await validate_input(self.hass, {CONF_URL: ws_address})
|
||||||
|
except CannotConnect:
|
||||||
|
return self.async_abort(reason="cannot_connect")
|
||||||
|
|
||||||
|
return await self._async_create_entry_or_abort()
|
||||||
|
|
||||||
|
async def _async_create_entry_or_abort(self) -> FlowResult:
|
||||||
|
"""Return a config entry for the flow or abort if already configured."""
|
||||||
|
assert self.ws_address is not None
|
||||||
|
|
||||||
|
if existing_config_entries := self._async_current_entries():
|
||||||
|
config_entry = existing_config_entries[0]
|
||||||
|
self.hass.config_entries.async_update_entry(
|
||||||
|
config_entry,
|
||||||
|
data={
|
||||||
|
**config_entry.data,
|
||||||
|
CONF_URL: self.ws_address,
|
||||||
|
CONF_USE_ADDON: self.use_addon,
|
||||||
|
CONF_INTEGRATION_CREATED_ADDON: self.integration_created_addon,
|
||||||
|
},
|
||||||
|
title=self.ws_address,
|
||||||
|
)
|
||||||
|
await self.hass.config_entries.async_reload(config_entry.entry_id)
|
||||||
|
raise AbortFlow("reconfiguration_successful")
|
||||||
|
|
||||||
|
# Abort any other flows that may be in progress
|
||||||
|
for progress in self._async_in_progress():
|
||||||
|
self.hass.config_entries.flow.async_abort(progress["flow_id"])
|
||||||
|
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=self.ws_address,
|
||||||
|
data={
|
||||||
|
CONF_URL: self.ws_address,
|
||||||
|
CONF_USE_ADDON: self.use_addon,
|
||||||
|
CONF_INTEGRATION_CREATED_ADDON: self.integration_created_addon,
|
||||||
|
},
|
||||||
|
)
|
10
homeassistant/components/matter/const.py
Normal file
10
homeassistant/components/matter/const.py
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
"""Constants for the Matter integration."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
ADDON_SLUG = "core_matter_server"
|
||||||
|
|
||||||
|
CONF_INTEGRATION_CREATED_ADDON = "integration_created_addon"
|
||||||
|
CONF_USE_ADDON = "use_addon"
|
||||||
|
|
||||||
|
DOMAIN = "matter"
|
||||||
|
LOGGER = logging.getLogger(__package__)
|
24
homeassistant/components/matter/device_platform.py
Normal file
24
homeassistant/components/matter/device_platform.py
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
"""All mappings of Matter devices to Home Assistant platforms."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from homeassistant.const import Platform
|
||||||
|
|
||||||
|
from .light import DEVICE_ENTITY as LIGHT_DEVICE_ENTITY
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from matter_server.common.models.device_types import DeviceType
|
||||||
|
|
||||||
|
from .entity import MatterEntityDescriptionBaseClass
|
||||||
|
|
||||||
|
|
||||||
|
DEVICE_PLATFORM: dict[
|
||||||
|
Platform,
|
||||||
|
dict[
|
||||||
|
type[DeviceType],
|
||||||
|
MatterEntityDescriptionBaseClass | list[MatterEntityDescriptionBaseClass],
|
||||||
|
],
|
||||||
|
] = {
|
||||||
|
Platform.LIGHT: LIGHT_DEVICE_ENTITY,
|
||||||
|
}
|
118
homeassistant/components/matter/entity.py
Normal file
118
homeassistant/components/matter/entity.py
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
"""Matter entity base class."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import abstractmethod
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
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 homeassistant.core import callback
|
||||||
|
from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from matter_server.client import MatterClient
|
||||||
|
from matter_server.common.models.node import MatterAttribute
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MatterEntityDescription:
|
||||||
|
"""Mixin to map a matter device to a Home Assistant entity."""
|
||||||
|
|
||||||
|
entity_cls: type[MatterEntity]
|
||||||
|
subscribe_attributes: tuple
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MatterEntityDescriptionBaseClass(EntityDescription, MatterEntityDescription):
|
||||||
|
"""For typing a base class that inherits from both entity descriptions."""
|
||||||
|
|
||||||
|
|
||||||
|
class MatterEntity(Entity):
|
||||||
|
"""Entity class for Matter devices."""
|
||||||
|
|
||||||
|
entity_description: MatterEntityDescriptionBaseClass
|
||||||
|
_attr_should_poll = False
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
matter_client: MatterClient,
|
||||||
|
node_device: AbstractMatterNodeDevice,
|
||||||
|
device_type_instance: MatterDeviceTypeInstance,
|
||||||
|
entity_description: MatterEntityDescriptionBaseClass,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the entity."""
|
||||||
|
self.matter_client = matter_client
|
||||||
|
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] = {}
|
||||||
|
self._attr_unique_id = f"{matter_client.server_info.compressed_fabric_id}-{node.unique_id}-{device_type_instance.endpoint}-{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)}}
|
||||||
|
|
||||||
|
async def async_added_to_hass(self) -> None:
|
||||||
|
"""Handle being added to Home Assistant."""
|
||||||
|
await super().async_added_to_hass()
|
||||||
|
|
||||||
|
# Subscribe to attribute updates.
|
||||||
|
for attr_cls in self.entity_description.subscribe_attributes:
|
||||||
|
if matter_attr := self.get_matter_attribute(attr_cls):
|
||||||
|
self._attributes_map[attr_cls] = matter_attr.path
|
||||||
|
self._unsubscribes.append(
|
||||||
|
self.matter_client.subscribe(
|
||||||
|
self._on_matter_event,
|
||||||
|
EventType.ATTRIBUTE_UPDATED,
|
||||||
|
self._device_type_instance.node.node_id,
|
||||||
|
matter_attr.path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
# not sure if this can happen, but just in case log it.
|
||||||
|
LOGGER.warning("Attribute not found on device: %s", attr_cls)
|
||||||
|
|
||||||
|
# make sure to update the attributes once
|
||||||
|
self._update_from_device()
|
||||||
|
|
||||||
|
async def async_will_remove_from_hass(self) -> None:
|
||||||
|
"""Run when entity will be removed from hass."""
|
||||||
|
for unsub in self._unsubscribes:
|
||||||
|
unsub()
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _on_matter_event(self, event: EventType, data: Any = None) -> None:
|
||||||
|
"""Call on update."""
|
||||||
|
self._update_from_device()
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
@callback
|
||||||
|
@abstractmethod
|
||||||
|
def _update_from_device(self) -> None:
|
||||||
|
"""Update data from Matter device."""
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def get_matter_attribute(self, attribute: type) -> MatterAttribute | None:
|
||||||
|
"""Lookup MatterAttribute instance on device instance by providing the attribute class."""
|
||||||
|
return next(
|
||||||
|
(
|
||||||
|
x
|
||||||
|
for x in self._device_type_instance.attributes
|
||||||
|
if x.attribute_type == attribute
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
173
homeassistant/components/matter/light.py
Normal file
173
homeassistant/components/matter/light.py
Normal file
|
@ -0,0 +1,173 @@
|
||||||
|
"""Matter light."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from functools import partial
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from chip.clusters import Objects as clusters
|
||||||
|
from matter_server.common.models import device_types
|
||||||
|
|
||||||
|
from homeassistant.components.light import (
|
||||||
|
ATTR_BRIGHTNESS,
|
||||||
|
ColorMode,
|
||||||
|
LightEntity,
|
||||||
|
LightEntityDescription,
|
||||||
|
)
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import Platform
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .entity import MatterEntity, MatterEntityDescriptionBaseClass
|
||||||
|
from .util import renormalize
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .adapter import MatterAdapter
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config_entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up Matter Light from Config Entry."""
|
||||||
|
matter: MatterAdapter = hass.data[DOMAIN][config_entry.entry_id]
|
||||||
|
matter.register_platform_handler(Platform.LIGHT, async_add_entities)
|
||||||
|
|
||||||
|
|
||||||
|
class MatterLight(MatterEntity, LightEntity):
|
||||||
|
"""Representation of a Matter light."""
|
||||||
|
|
||||||
|
entity_description: MatterLightEntityDescription
|
||||||
|
|
||||||
|
def _supports_brightness(self) -> bool:
|
||||||
|
"""Return if device supports brightness."""
|
||||||
|
return (
|
||||||
|
clusters.LevelControl.Attributes.CurrentLevel
|
||||||
|
in self.entity_description.subscribe_attributes
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||||
|
"""Turn light on."""
|
||||||
|
if ATTR_BRIGHTNESS not in kwargs or not self._supports_brightness():
|
||||||
|
await self.matter_client.send_device_command(
|
||||||
|
node_id=self._device_type_instance.node.node_id,
|
||||||
|
endpoint=self._device_type_instance.endpoint,
|
||||||
|
command=clusters.OnOff.Commands.On(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
level_control = self._device_type_instance.get_cluster(clusters.LevelControl)
|
||||||
|
level = round(
|
||||||
|
renormalize(
|
||||||
|
kwargs[ATTR_BRIGHTNESS],
|
||||||
|
(0, 255),
|
||||||
|
(level_control.minLevel, level_control.maxLevel),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.matter_client.send_device_command(
|
||||||
|
node_id=self._device_type_instance.node.node_id,
|
||||||
|
endpoint=self._device_type_instance.endpoint,
|
||||||
|
command=clusters.LevelControl.Commands.MoveToLevelWithOnOff(
|
||||||
|
level=level,
|
||||||
|
# It's required in TLV. We don't implement transition time yet.
|
||||||
|
transitionTime=0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
|
"""Turn light off."""
|
||||||
|
await self.matter_client.send_device_command(
|
||||||
|
node_id=self._device_type_instance.node.node_id,
|
||||||
|
endpoint=self._device_type_instance.endpoint,
|
||||||
|
command=clusters.OnOff.Commands.Off(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _update_from_device(self) -> None:
|
||||||
|
"""Update from device."""
|
||||||
|
if self._attr_supported_color_modes is None:
|
||||||
|
if self._supports_brightness():
|
||||||
|
self._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
|
||||||
|
|
||||||
|
if attr := self.get_matter_attribute(clusters.OnOff.Attributes.OnOff):
|
||||||
|
self._attr_is_on = attr.value
|
||||||
|
|
||||||
|
if (
|
||||||
|
clusters.LevelControl.Attributes.CurrentLevel
|
||||||
|
in self.entity_description.subscribe_attributes
|
||||||
|
):
|
||||||
|
level_control = self._device_type_instance.get_cluster(
|
||||||
|
clusters.LevelControl
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert brightness to Home Assistant = 0..255
|
||||||
|
self._attr_brightness = round(
|
||||||
|
renormalize(
|
||||||
|
level_control.currentLevel,
|
||||||
|
(level_control.minLevel, level_control.maxLevel),
|
||||||
|
(0, 255),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MatterLightEntityDescription(
|
||||||
|
LightEntityDescription,
|
||||||
|
MatterEntityDescriptionBaseClass,
|
||||||
|
):
|
||||||
|
"""Matter light entity description."""
|
||||||
|
|
||||||
|
|
||||||
|
# You can't set default values on inherited data classes
|
||||||
|
MatterLightEntityDescriptionFactory = partial(
|
||||||
|
MatterLightEntityDescription, entity_cls=MatterLight
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mapping of a Matter Device type to Light Entity Description.
|
||||||
|
# A Matter device type (instance) can consist of multiple attributes.
|
||||||
|
# For example a Color Light which has an attribute to control brightness
|
||||||
|
# but also for color.
|
||||||
|
|
||||||
|
DEVICE_ENTITY: dict[
|
||||||
|
type[device_types.DeviceType],
|
||||||
|
MatterEntityDescriptionBaseClass | list[MatterEntityDescriptionBaseClass],
|
||||||
|
] = {
|
||||||
|
device_types.OnOffLight: MatterLightEntityDescriptionFactory(
|
||||||
|
key=device_types.OnOffLight,
|
||||||
|
subscribe_attributes=(clusters.OnOff.Attributes.OnOff,),
|
||||||
|
),
|
||||||
|
device_types.DimmableLight: MatterLightEntityDescriptionFactory(
|
||||||
|
key=device_types.DimmableLight,
|
||||||
|
subscribe_attributes=(
|
||||||
|
clusters.OnOff.Attributes.OnOff,
|
||||||
|
clusters.LevelControl.Attributes.CurrentLevel,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
device_types.DimmablePlugInUnit: MatterLightEntityDescriptionFactory(
|
||||||
|
key=device_types.DimmablePlugInUnit,
|
||||||
|
subscribe_attributes=(
|
||||||
|
clusters.OnOff.Attributes.OnOff,
|
||||||
|
clusters.LevelControl.Attributes.CurrentLevel,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
device_types.ColorTemperatureLight: MatterLightEntityDescriptionFactory(
|
||||||
|
key=device_types.ColorTemperatureLight,
|
||||||
|
subscribe_attributes=(
|
||||||
|
clusters.OnOff.Attributes.OnOff,
|
||||||
|
clusters.LevelControl.Attributes.CurrentLevel,
|
||||||
|
clusters.ColorControl,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
device_types.ExtendedColorLight: MatterLightEntityDescriptionFactory(
|
||||||
|
key=device_types.ExtendedColorLight,
|
||||||
|
subscribe_attributes=(
|
||||||
|
clusters.OnOff.Attributes.OnOff,
|
||||||
|
clusters.LevelControl.Attributes.CurrentLevel,
|
||||||
|
clusters.ColorControl,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
10
homeassistant/components/matter/manifest.json
Normal file
10
homeassistant/components/matter/manifest.json
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"domain": "matter",
|
||||||
|
"name": "Matter (BETA)",
|
||||||
|
"config_flow": true,
|
||||||
|
"documentation": "https://www.home-assistant.io/integrations/matter",
|
||||||
|
"requirements": ["python-matter-server==1.0.6"],
|
||||||
|
"dependencies": ["websocket_api"],
|
||||||
|
"codeowners": ["@MartinHjelmare", "@marcelveldt"],
|
||||||
|
"iot_class": "local_push"
|
||||||
|
}
|
66
homeassistant/components/matter/services.yaml
Normal file
66
homeassistant/components/matter/services.yaml
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
commission:
|
||||||
|
name: Commission device
|
||||||
|
description: >
|
||||||
|
Add a new device to your Matter network.
|
||||||
|
fields:
|
||||||
|
code:
|
||||||
|
name: Pairing code
|
||||||
|
description: The pairing code for the device.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
accept_shared_device:
|
||||||
|
name: Accept shared device
|
||||||
|
description: >
|
||||||
|
Add a shared device to your Matter network.
|
||||||
|
fields:
|
||||||
|
pin:
|
||||||
|
name: Pin code
|
||||||
|
description: The pin code for the device.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
|
||||||
|
set_wifi:
|
||||||
|
name: Set Wi-Fi credentials
|
||||||
|
description: >
|
||||||
|
The Wi-Fi credentials will be sent as part of commissioning to a Matter device so it can connect to the Wi-Fi network.
|
||||||
|
fields:
|
||||||
|
ssid:
|
||||||
|
name: Network name
|
||||||
|
description: The SSID network name.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
password:
|
||||||
|
name: Password
|
||||||
|
description: The Wi-Fi network password.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
type: password
|
||||||
|
set_thread:
|
||||||
|
name: Set Thread network operational dataset
|
||||||
|
description: >
|
||||||
|
The Thread keys will be used as part of commissioning to let a Matter device join the Thread network.
|
||||||
|
|
||||||
|
Get keys by running `ot-ctl dataset active -x` on the Open Thread Border Router.
|
||||||
|
fields:
|
||||||
|
thread_operation_dataset:
|
||||||
|
name: Thread Operational Dataset
|
||||||
|
description: The Thread Operational Dataset to set.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
open_commissioning_window:
|
||||||
|
name: Open Commissioning Window
|
||||||
|
description: >
|
||||||
|
Allow adding one of your devices to another Matter network by opening the commissioning window for this Matter device for 60 seconds.
|
||||||
|
fields:
|
||||||
|
device_id:
|
||||||
|
name: Device
|
||||||
|
description: The Matter device to add to the other Matter network.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
device:
|
||||||
|
integration: matter
|
47
homeassistant/components/matter/strings.json
Normal file
47
homeassistant/components/matter/strings.json
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"flow_title": "{name}",
|
||||||
|
"step": {
|
||||||
|
"manual": {
|
||||||
|
"data": {
|
||||||
|
"url": "[%key:common::config_flow::data::url%]"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"on_supervisor": {
|
||||||
|
"title": "Select connection method",
|
||||||
|
"description": "Do you want to use the official Matter Server Supervisor add-on?\n\nIf you are already running the Matter Server in another add-on, in a custom container, natively etc., then do not select this option.",
|
||||||
|
"data": {
|
||||||
|
"use_addon": "Use the official Matter Server Supervisor add-on"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"install_addon": {
|
||||||
|
"title": "The add-on installation has started"
|
||||||
|
},
|
||||||
|
"start_addon": {
|
||||||
|
"title": "Starting add-on."
|
||||||
|
},
|
||||||
|
"hassio_confirm": {
|
||||||
|
"title": "Set up the Matter integration with the Matter Server add-on"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
|
"invalid_server_version": "The Matter server is not the correct version",
|
||||||
|
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"addon_get_discovery_info_failed": "Failed to get Matter Server add-on discovery info.",
|
||||||
|
"addon_info_failed": "Failed to get Matter Server add-on info.",
|
||||||
|
"addon_install_failed": "Failed to install the Matter Server add-on.",
|
||||||
|
"addon_start_failed": "Failed to start the Matter Server add-on.",
|
||||||
|
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||||
|
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||||
|
"not_matter_addon": "Discovered add-on is not the official Matter Server add-on.",
|
||||||
|
"reconfiguration_successful": "Successfully reconfigured the Matter integration."
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"install_addon": "Please wait while the Matter Server add-on installation finishes. This can take several minutes.",
|
||||||
|
"start_addon": "Please wait while the Matter Server add-on starts. This add-on is what powers Matter in Home Assistant. This may take some seconds."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
47
homeassistant/components/matter/translations/en.json
Normal file
47
homeassistant/components/matter/translations/en.json
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"abort": {
|
||||||
|
"addon_get_discovery_info_failed": "Failed to get Matter Server add-on discovery info.",
|
||||||
|
"addon_info_failed": "Failed to get Matter Server add-on info.",
|
||||||
|
"addon_install_failed": "Failed to install the Matter Server add-on.",
|
||||||
|
"addon_start_failed": "Failed to start the Matter Server add-on.",
|
||||||
|
"already_configured": "Device is already configured",
|
||||||
|
"already_in_progress": "Configuration flow is already in progress",
|
||||||
|
"not_matter_addon": "Discovered add-on is not the official Matter Server add-on.",
|
||||||
|
"reconfiguration_successful": "Successfully reconfigured the Matter integration."
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "Failed to connect",
|
||||||
|
"invalid_server_version": "The Matter server is not the correct version",
|
||||||
|
"unknown": "Unexpected error"
|
||||||
|
},
|
||||||
|
"flow_title": "{name}",
|
||||||
|
"progress": {
|
||||||
|
"install_addon": "Please wait while the Matter Server add-on installation finishes. This can take several minutes.",
|
||||||
|
"start_addon": "Please wait while the Matter Server add-on starts. This add-on is what powers Matter in Home Assistant. This may take some seconds."
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"hassio_confirm": {
|
||||||
|
"title": "Set up the Matter integration with the Matter Server add-on"
|
||||||
|
},
|
||||||
|
"install_addon": {
|
||||||
|
"title": "The add-on installation has started"
|
||||||
|
},
|
||||||
|
"manual": {
|
||||||
|
"data": {
|
||||||
|
"url": "URL"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"on_supervisor": {
|
||||||
|
"data": {
|
||||||
|
"use_addon": "Use the official Matter Server Supervisor add-on"
|
||||||
|
},
|
||||||
|
"description": "Do you want to use the official Matter Server Supervisor add-on?\n\nIf you are already running the Matter Server in another add-on, in a custom container, natively etc., then do not select this option.",
|
||||||
|
"title": "Select connection method"
|
||||||
|
},
|
||||||
|
"start_addon": {
|
||||||
|
"title": "Starting add-on."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
11
homeassistant/components/matter/util.py
Normal file
11
homeassistant/components/matter/util.py
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
"""Provide integration utilities."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def renormalize(
|
||||||
|
number: float, from_range: tuple[float, float], to_range: tuple[float, float]
|
||||||
|
) -> float:
|
||||||
|
"""Change value from from_range to to_range."""
|
||||||
|
delta1 = from_range[1] - from_range[0]
|
||||||
|
delta2 = to_range[1] - to_range[0]
|
||||||
|
return (delta2 * (number - from_range[0]) / delta1) + to_range[0]
|
|
@ -235,6 +235,7 @@ FLOWS = {
|
||||||
"lutron_caseta",
|
"lutron_caseta",
|
||||||
"lyric",
|
"lyric",
|
||||||
"mailgun",
|
"mailgun",
|
||||||
|
"matter",
|
||||||
"mazda",
|
"mazda",
|
||||||
"meater",
|
"meater",
|
||||||
"melcloud",
|
"melcloud",
|
||||||
|
|
|
@ -3046,6 +3046,12 @@
|
||||||
"config_flow": false,
|
"config_flow": false,
|
||||||
"iot_class": "cloud_push"
|
"iot_class": "cloud_push"
|
||||||
},
|
},
|
||||||
|
"matter": {
|
||||||
|
"name": "Matter (BETA)",
|
||||||
|
"integration_type": "hub",
|
||||||
|
"config_flow": true,
|
||||||
|
"iot_class": "local_push"
|
||||||
|
},
|
||||||
"mazda": {
|
"mazda": {
|
||||||
"name": "Mazda Connected Services",
|
"name": "Mazda Connected Services",
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
|
|
10
mypy.ini
10
mypy.ini
|
@ -1543,6 +1543,16 @@ disallow_untyped_defs = true
|
||||||
warn_return_any = true
|
warn_return_any = true
|
||||||
warn_unreachable = true
|
warn_unreachable = true
|
||||||
|
|
||||||
|
[mypy-homeassistant.components.matter.*]
|
||||||
|
check_untyped_defs = true
|
||||||
|
disallow_incomplete_defs = true
|
||||||
|
disallow_subclassing_any = true
|
||||||
|
disallow_untyped_calls = true
|
||||||
|
disallow_untyped_decorators = true
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unreachable = true
|
||||||
|
|
||||||
[mypy-homeassistant.components.media_player.*]
|
[mypy-homeassistant.components.media_player.*]
|
||||||
check_untyped_defs = true
|
check_untyped_defs = true
|
||||||
disallow_incomplete_defs = true
|
disallow_incomplete_defs = true
|
||||||
|
|
|
@ -2029,6 +2029,9 @@ python-kasa==0.5.0
|
||||||
# homeassistant.components.lirc
|
# homeassistant.components.lirc
|
||||||
# python-lirc==1.2.3
|
# python-lirc==1.2.3
|
||||||
|
|
||||||
|
# homeassistant.components.matter
|
||||||
|
python-matter-server==1.0.6
|
||||||
|
|
||||||
# homeassistant.components.xiaomi_miio
|
# homeassistant.components.xiaomi_miio
|
||||||
python-miio==0.5.12
|
python-miio==0.5.12
|
||||||
|
|
||||||
|
|
|
@ -1416,6 +1416,9 @@ python-juicenet==1.1.0
|
||||||
# homeassistant.components.tplink
|
# homeassistant.components.tplink
|
||||||
python-kasa==0.5.0
|
python-kasa==0.5.0
|
||||||
|
|
||||||
|
# homeassistant.components.matter
|
||||||
|
python-matter-server==1.0.6
|
||||||
|
|
||||||
# homeassistant.components.xiaomi_miio
|
# homeassistant.components.xiaomi_miio
|
||||||
python-miio==0.5.12
|
python-miio==0.5.12
|
||||||
|
|
||||||
|
|
1
tests/components/matter/__init__.py
Normal file
1
tests/components/matter/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
"""Tests for the Matter integration."""
|
146
tests/components/matter/common.py
Normal file
146
tests/components/matter/common.py
Normal file
|
@ -0,0 +1,146 @@
|
||||||
|
"""Provide common test tools."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from functools import cache
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from matter_server.client import MatterClient
|
||||||
|
from matter_server.common.models.node import MatterNode
|
||||||
|
from matter_server.common.models.server_information import ServerInfo
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry, load_fixture
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
MOCK_FABRIC_ID = 12341234
|
||||||
|
MOCK_COMPR_FABRIC_ID = 1234
|
||||||
|
|
||||||
|
# TEMP: Tests need to be fixed
|
||||||
|
pytestmark = pytest.mark.skip("all tests still WIP")
|
||||||
|
|
||||||
|
|
||||||
|
class MockClient(MatterClient):
|
||||||
|
"""Represent a mock Matter client."""
|
||||||
|
|
||||||
|
mock_client_disconnect: asyncio.Event
|
||||||
|
mock_commands: dict[type, Any] = {}
|
||||||
|
mock_sent_commands: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialize the mock client."""
|
||||||
|
super().__init__("mock-url", None)
|
||||||
|
self.mock_commands: dict[type, Any] = {}
|
||||||
|
self.mock_sent_commands = []
|
||||||
|
self.server_info = ServerInfo(
|
||||||
|
fabric_id=MOCK_FABRIC_ID, compressed_fabric_id=MOCK_COMPR_FABRIC_ID
|
||||||
|
)
|
||||||
|
|
||||||
|
async def connect(self) -> None:
|
||||||
|
"""Connect to the Matter server."""
|
||||||
|
self.server_info = Mock(compressed_abric_d=MOCK_COMPR_FABRIC_ID)
|
||||||
|
|
||||||
|
async def listen(self, driver_ready: asyncio.Event) -> None:
|
||||||
|
"""Listen for events."""
|
||||||
|
driver_ready.set()
|
||||||
|
self.mock_client_disconnect = asyncio.Event()
|
||||||
|
await self.mock_client_disconnect.wait()
|
||||||
|
|
||||||
|
def mock_command(self, command_type: type, response: Any) -> None:
|
||||||
|
"""Mock a command."""
|
||||||
|
self.mock_commands[command_type] = response
|
||||||
|
|
||||||
|
async def async_send_command(
|
||||||
|
self,
|
||||||
|
command: str,
|
||||||
|
args: dict[str, Any],
|
||||||
|
require_schema: int | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Send mock commands."""
|
||||||
|
if command == "device_controller.SendCommand" and (
|
||||||
|
(cmd_type := type(args.get("payload"))) in self.mock_commands
|
||||||
|
):
|
||||||
|
self.mock_sent_commands.append(args)
|
||||||
|
return self.mock_commands[cmd_type]
|
||||||
|
|
||||||
|
return await super().async_send_command(command, args, require_schema)
|
||||||
|
|
||||||
|
async def async_send_command_no_wait(
|
||||||
|
self, command: str, args: dict[str, Any], require_schema: int | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Send a command without waiting for the response."""
|
||||||
|
if command == "SendCommand" and (
|
||||||
|
(cmd_type := type(args.get("payload"))) in self.mock_commands
|
||||||
|
):
|
||||||
|
self.mock_sent_commands.append(args)
|
||||||
|
return self.mock_commands[cmd_type]
|
||||||
|
|
||||||
|
return await super().async_send_command_no_wait(command, args, require_schema)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def mock_matter() -> Mock:
|
||||||
|
"""Mock matter fixture."""
|
||||||
|
return await get_mock_matter()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_mock_matter() -> Mock:
|
||||||
|
"""Get mock Matter."""
|
||||||
|
return Mock(
|
||||||
|
adapter=Mock(logger=logging.getLogger("mock_matter")), client=MockClient()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def load_node_fixture(fixture: str) -> str:
|
||||||
|
"""Load a fixture."""
|
||||||
|
return load_fixture(f"matter/nodes/{fixture}.json")
|
||||||
|
|
||||||
|
|
||||||
|
def load_and_parse_node_fixture(fixture: str) -> dict[str, Any]:
|
||||||
|
"""Load and parse a node fixture."""
|
||||||
|
return json.loads(load_node_fixture(fixture))
|
||||||
|
|
||||||
|
|
||||||
|
async def setup_integration_with_node_fixture(
|
||||||
|
hass: HomeAssistant, hass_storage: dict[str, Any], node_fixture: str
|
||||||
|
) -> MatterNode:
|
||||||
|
"""Set up Matter integration with fixture as node."""
|
||||||
|
node_data = load_and_parse_node_fixture(node_fixture)
|
||||||
|
node = MatterNode(
|
||||||
|
await get_mock_matter(),
|
||||||
|
node_data,
|
||||||
|
)
|
||||||
|
config_entry = MockConfigEntry(
|
||||||
|
domain="matter", data={"url": "http://mock-matter-server-url"}
|
||||||
|
)
|
||||||
|
config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
storage_key = f"matter_{config_entry.entry_id}"
|
||||||
|
hass_storage[storage_key] = {
|
||||||
|
"version": 1,
|
||||||
|
"minor_version": 0,
|
||||||
|
"key": storage_key,
|
||||||
|
"data": {
|
||||||
|
"compressed_fabric_id": MOCK_COMPR_FABRIC_ID,
|
||||||
|
"next_node_id": 4339,
|
||||||
|
"nodes": {str(node.node_id): node_data},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"matter_server.client.matter.Client", return_value=node.matter.client
|
||||||
|
), patch(
|
||||||
|
"matter_server.client.model.node.MatterDeviceTypeInstance.subscribe_updates",
|
||||||
|
), patch(
|
||||||
|
"matter_server.client.model.node.MatterDeviceTypeInstance.update_attributes"
|
||||||
|
):
|
||||||
|
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
return node
|
187
tests/components/matter/conftest.py
Normal file
187
tests/components/matter/conftest.py
Normal file
|
@ -0,0 +1,187 @@
|
||||||
|
"""Provide common fixtures."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import AsyncGenerator, Generator
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="matter_client")
|
||||||
|
async def matter_client_fixture() -> AsyncGenerator[MagicMock, None]:
|
||||||
|
"""Fixture for a Matter client."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.matter.MatterClient", autospec=True
|
||||||
|
) as client_class:
|
||||||
|
client = client_class.return_value
|
||||||
|
|
||||||
|
async def connect() -> None:
|
||||||
|
"""Mock connect."""
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
client.connected = True
|
||||||
|
|
||||||
|
async def listen(init_ready: asyncio.Event | None) -> None:
|
||||||
|
"""Mock listen."""
|
||||||
|
if init_ready is not None:
|
||||||
|
init_ready.set()
|
||||||
|
|
||||||
|
client.connect = AsyncMock(side_effect=connect)
|
||||||
|
client.start_listening = AsyncMock(side_effect=listen)
|
||||||
|
|
||||||
|
yield client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="integration")
|
||||||
|
async def integration_fixture(
|
||||||
|
hass: HomeAssistant, matter_client: MagicMock
|
||||||
|
) -> MockConfigEntry:
|
||||||
|
"""Set up the Matter integration."""
|
||||||
|
entry = MockConfigEntry(domain="matter", data={"url": "ws://localhost:5580/ws"})
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
await hass.config_entries.async_setup(entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="create_backup")
|
||||||
|
def create_backup_fixture() -> Generator[AsyncMock, None, None]:
|
||||||
|
"""Mock Supervisor create backup of add-on."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.hassio.addon_manager.async_create_backup"
|
||||||
|
) as create_backup:
|
||||||
|
yield create_backup
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="addon_store_info")
|
||||||
|
def addon_store_info_fixture() -> Generator[AsyncMock, None, None]:
|
||||||
|
"""Mock Supervisor add-on store info."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.hassio.addon_manager.async_get_addon_store_info"
|
||||||
|
) as addon_store_info:
|
||||||
|
addon_store_info.return_value = {
|
||||||
|
"installed": None,
|
||||||
|
"state": None,
|
||||||
|
"version": "1.0.0",
|
||||||
|
}
|
||||||
|
yield addon_store_info
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="addon_info")
|
||||||
|
def addon_info_fixture() -> Generator[AsyncMock, None, None]:
|
||||||
|
"""Mock Supervisor add-on info."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.hassio.addon_manager.async_get_addon_info",
|
||||||
|
) as addon_info:
|
||||||
|
addon_info.return_value = {
|
||||||
|
"hostname": None,
|
||||||
|
"options": {},
|
||||||
|
"state": None,
|
||||||
|
"update_available": False,
|
||||||
|
"version": None,
|
||||||
|
}
|
||||||
|
yield addon_info
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="addon_not_installed")
|
||||||
|
def addon_not_installed_fixture(
|
||||||
|
addon_store_info: AsyncMock, addon_info: AsyncMock
|
||||||
|
) -> AsyncMock:
|
||||||
|
"""Mock add-on not installed."""
|
||||||
|
return addon_info
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="addon_installed")
|
||||||
|
def addon_installed_fixture(
|
||||||
|
addon_store_info: AsyncMock, addon_info: AsyncMock
|
||||||
|
) -> AsyncMock:
|
||||||
|
"""Mock add-on already installed but not running."""
|
||||||
|
addon_store_info.return_value = {
|
||||||
|
"installed": "1.0.0",
|
||||||
|
"state": "stopped",
|
||||||
|
"version": "1.0.0",
|
||||||
|
}
|
||||||
|
addon_info.return_value["hostname"] = "core-matter-server"
|
||||||
|
addon_info.return_value["state"] = "stopped"
|
||||||
|
addon_info.return_value["version"] = "1.0.0"
|
||||||
|
return addon_info
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="addon_running")
|
||||||
|
def addon_running_fixture(
|
||||||
|
addon_store_info: AsyncMock, addon_info: AsyncMock
|
||||||
|
) -> AsyncMock:
|
||||||
|
"""Mock add-on already running."""
|
||||||
|
addon_store_info.return_value = {
|
||||||
|
"installed": "1.0.0",
|
||||||
|
"state": "started",
|
||||||
|
"version": "1.0.0",
|
||||||
|
}
|
||||||
|
addon_info.return_value["hostname"] = "core-matter-server"
|
||||||
|
addon_info.return_value["state"] = "started"
|
||||||
|
addon_info.return_value["version"] = "1.0.0"
|
||||||
|
return addon_info
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="install_addon")
|
||||||
|
def install_addon_fixture(
|
||||||
|
addon_store_info: AsyncMock, addon_info: AsyncMock
|
||||||
|
) -> Generator[AsyncMock, None, None]:
|
||||||
|
"""Mock install add-on."""
|
||||||
|
|
||||||
|
async def install_addon_side_effect(hass: HomeAssistant, slug: str) -> None:
|
||||||
|
"""Mock install add-on."""
|
||||||
|
addon_store_info.return_value = {
|
||||||
|
"installed": "1.0.0",
|
||||||
|
"state": "stopped",
|
||||||
|
"version": "1.0.0",
|
||||||
|
}
|
||||||
|
addon_info.return_value["state"] = "stopped"
|
||||||
|
addon_info.return_value["version"] = "1.0.0"
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.hassio.addon_manager.async_install_addon"
|
||||||
|
) as install_addon:
|
||||||
|
install_addon.side_effect = install_addon_side_effect
|
||||||
|
yield install_addon
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="start_addon")
|
||||||
|
def start_addon_fixture() -> Generator[AsyncMock, None, None]:
|
||||||
|
"""Mock start add-on."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.hassio.addon_manager.async_start_addon"
|
||||||
|
) as start_addon:
|
||||||
|
yield start_addon
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="stop_addon")
|
||||||
|
def stop_addon_fixture() -> Generator[AsyncMock, None, None]:
|
||||||
|
"""Mock stop add-on."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.hassio.addon_manager.async_stop_addon"
|
||||||
|
) as stop_addon:
|
||||||
|
yield stop_addon
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="uninstall_addon")
|
||||||
|
def uninstall_addon_fixture() -> Generator[AsyncMock, None, None]:
|
||||||
|
"""Mock uninstall add-on."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.hassio.addon_manager.async_uninstall_addon"
|
||||||
|
) as uninstall_addon:
|
||||||
|
yield uninstall_addon
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="update_addon")
|
||||||
|
def update_addon_fixture() -> Generator[AsyncMock, None, None]:
|
||||||
|
"""Mock update add-on."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.hassio.addon_manager.async_update_addon"
|
||||||
|
) as update_addon:
|
||||||
|
yield update_addon
|
|
@ -0,0 +1,174 @@
|
||||||
|
{
|
||||||
|
"attributes": {
|
||||||
|
"0": {
|
||||||
|
"Descriptor": {
|
||||||
|
"deviceTypeList": [
|
||||||
|
{
|
||||||
|
"type": 22,
|
||||||
|
"revision": 1,
|
||||||
|
"_type": "chip.clusters.Objects.Descriptor.Structs.DeviceTypeStruct"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 14,
|
||||||
|
"revision": 1,
|
||||||
|
"_type": "chip.clusters.Objects.Descriptor.Structs.DeviceTypeStruct"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"serverList": [29, 37, 40, 48, 49, 50, 51, 60, 62, 64, 65],
|
||||||
|
"clientList": [],
|
||||||
|
"partsList": [9, 10],
|
||||||
|
"generatedCommandList": [],
|
||||||
|
"acceptedCommandList": [],
|
||||||
|
"attributeList": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
|
||||||
|
"featureMap": 0,
|
||||||
|
"clusterRevision": 1,
|
||||||
|
"_type": "chip.clusters.Objects.Descriptor"
|
||||||
|
},
|
||||||
|
"Basic": {
|
||||||
|
"dataModelRevision": 0,
|
||||||
|
"vendorName": "Mock Vendor",
|
||||||
|
"vendorID": 1234,
|
||||||
|
"productName": "Mock Bridge",
|
||||||
|
"productID": 2,
|
||||||
|
"nodeLabel": "My Mock Bridge",
|
||||||
|
"location": "nl",
|
||||||
|
"hardwareVersion": 123,
|
||||||
|
"hardwareVersionString": "TEST_VERSION",
|
||||||
|
"softwareVersion": 12345,
|
||||||
|
"softwareVersionString": "123.4.5",
|
||||||
|
"manufacturingDate": null,
|
||||||
|
"partNumber": null,
|
||||||
|
"productURL": null,
|
||||||
|
"productLabel": null,
|
||||||
|
"serialNumber": null,
|
||||||
|
"localConfigDisabled": null,
|
||||||
|
"reachable": null,
|
||||||
|
"uniqueID": "mock-hub-id",
|
||||||
|
"capabilityMinima": null,
|
||||||
|
"generatedCommandList": [],
|
||||||
|
"acceptedCommandList": [],
|
||||||
|
"attributeList": [
|
||||||
|
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 18, 65528, 65529, 65531, 65532,
|
||||||
|
65533
|
||||||
|
],
|
||||||
|
"featureMap": 0,
|
||||||
|
"clusterRevision": 3,
|
||||||
|
"_type": "chip.clusters.Objects.Basic"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"9": {
|
||||||
|
"OnOff": {
|
||||||
|
"onOff": true,
|
||||||
|
"globalSceneControl": true,
|
||||||
|
"onTime": 0,
|
||||||
|
"offWaitTime": 0,
|
||||||
|
"startUpOnOff": 0,
|
||||||
|
"generatedCommandList": [],
|
||||||
|
"acceptedCommandList": [0, 1, 2, 64, 65, 66],
|
||||||
|
"attributeList": [
|
||||||
|
0, 16384, 16385, 16386, 16387, 65528, 65529, 65531, 65532, 65533
|
||||||
|
],
|
||||||
|
"featureMap": 1,
|
||||||
|
"clusterRevision": 4,
|
||||||
|
"_type": "chip.clusters.Objects.OnOff"
|
||||||
|
},
|
||||||
|
"Descriptor": {
|
||||||
|
"deviceTypeList": [
|
||||||
|
{
|
||||||
|
"type": 256,
|
||||||
|
"revision": 1,
|
||||||
|
"_type": "chip.clusters.Objects.Descriptor.Structs.DeviceTypeStruct"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 19,
|
||||||
|
"revision": 1,
|
||||||
|
"_type": "chip.clusters.Objects.Descriptor.Structs.DeviceTypeStruct"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"serverList": [6, 29, 57, 768, 8, 40],
|
||||||
|
"clientList": [],
|
||||||
|
"partsList": [],
|
||||||
|
"generatedCommandList": [],
|
||||||
|
"acceptedCommandList": [],
|
||||||
|
"attributeList": [0, 1, 2, 3, 65528, 65529, 65531, 65533],
|
||||||
|
"featureMap": null,
|
||||||
|
"clusterRevision": 1,
|
||||||
|
"_type": "chip.clusters.Objects.Descriptor"
|
||||||
|
},
|
||||||
|
"BridgedDeviceBasic": {
|
||||||
|
"nodeLabel": "Kitchen Ceiling",
|
||||||
|
"reachable": true,
|
||||||
|
"vendorID": 1234,
|
||||||
|
"softwareVersionString": "67.8.9",
|
||||||
|
"softwareVersion": 6789,
|
||||||
|
"vendorName": "Mock Vendor",
|
||||||
|
"productName": "Mock Light",
|
||||||
|
"uniqueID": "mock-id-kitchen-ceiling",
|
||||||
|
"generatedCommandList": [],
|
||||||
|
"acceptedCommandList": [],
|
||||||
|
"attributeList": [
|
||||||
|
5, 17, 2, 4, 10, 9, 1, 3, 18, 65528, 65529, 65531, 65532, 65533
|
||||||
|
],
|
||||||
|
"_type": "chip.clusters.Objects.BridgedDeviceBasic"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"10": {
|
||||||
|
"OnOff": {
|
||||||
|
"onOff": false,
|
||||||
|
"globalSceneControl": true,
|
||||||
|
"onTime": 0,
|
||||||
|
"offWaitTime": 0,
|
||||||
|
"startUpOnOff": 0,
|
||||||
|
"generatedCommandList": [],
|
||||||
|
"acceptedCommandList": [0, 1, 2, 64, 65, 66],
|
||||||
|
"attributeList": [
|
||||||
|
0, 16384, 16385, 16386, 16387, 65528, 65529, 65531, 65532, 65533
|
||||||
|
],
|
||||||
|
"featureMap": 1,
|
||||||
|
"clusterRevision": 4,
|
||||||
|
"_type": "chip.clusters.Objects.OnOff"
|
||||||
|
},
|
||||||
|
"Descriptor": {
|
||||||
|
"deviceTypeList": [
|
||||||
|
{
|
||||||
|
"type": 256,
|
||||||
|
"revision": 1,
|
||||||
|
"_type": "chip.clusters.Objects.Descriptor.Structs.DeviceTypeStruct"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 19,
|
||||||
|
"revision": 1,
|
||||||
|
"_type": "chip.clusters.Objects.Descriptor.Structs.DeviceTypeStruct"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"serverList": [6, 29, 57, 768, 40],
|
||||||
|
"clientList": [],
|
||||||
|
"partsList": [],
|
||||||
|
"generatedCommandList": [],
|
||||||
|
"acceptedCommandList": [],
|
||||||
|
"attributeList": [0, 1, 2, 3, 65528, 65529, 65531, 65533],
|
||||||
|
"featureMap": null,
|
||||||
|
"clusterRevision": 1,
|
||||||
|
"_type": "chip.clusters.Objects.Descriptor"
|
||||||
|
},
|
||||||
|
"BridgedDeviceBasic": {
|
||||||
|
"nodeLabel": "Living Room Ceiling",
|
||||||
|
"reachable": true,
|
||||||
|
"vendorID": 1234,
|
||||||
|
"softwareVersionString": "1.49.1",
|
||||||
|
"softwareVersion": 19988481,
|
||||||
|
"vendorName": "Mock Vendor",
|
||||||
|
"productName": "Mock Light",
|
||||||
|
"uniqueID": "mock-id-living-room-ceiling",
|
||||||
|
"generatedCommandList": [],
|
||||||
|
"acceptedCommandList": [],
|
||||||
|
"attributeList": [
|
||||||
|
5, 17, 2, 4, 10, 9, 1, 3, 18, 65528, 65529, 65531, 65532, 65533
|
||||||
|
],
|
||||||
|
"_type": "chip.clusters.Objects.BridgedDeviceBasic"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"events": [],
|
||||||
|
"node_id": 4338
|
||||||
|
}
|
882
tests/components/matter/fixtures/nodes/lighting-example-app.json
Normal file
882
tests/components/matter/fixtures/nodes/lighting-example-app.json
Normal file
|
@ -0,0 +1,882 @@
|
||||||
|
{
|
||||||
|
"attributes": {
|
||||||
|
"0": {
|
||||||
|
"Groups": {
|
||||||
|
"nameSupport": 128,
|
||||||
|
"generatedCommandList": [0, 1, 2, 3],
|
||||||
|
"acceptedCommandList": [0, 1, 2, 3, 4, 5],
|
||||||
|
"attributeList": [0, 65528, 65529, 65531, 65532, 65533],
|
||||||
|
"featureMap": 0,
|
||||||
|
"clusterRevision": 4,
|
||||||
|
"_type": "chip.clusters.Objects.Groups"
|
||||||
|
},
|
||||||
|
"Descriptor": {
|
||||||
|
"deviceTypeList": [
|
||||||
|
{
|
||||||
|
"type": 22,
|
||||||
|
"revision": 1,
|
||||||
|
"_type": "chip.clusters.Objects.Descriptor.Structs.DeviceTypeStruct"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"serverList": [
|
||||||
|
4, 29, 31, 40, 42, 43, 44, 48, 49, 50, 51, 52, 53, 54, 55, 59, 60, 62,
|
||||||
|
63, 64, 65
|
||||||
|
],
|
||||||
|
"clientList": [41],
|
||||||
|
"partsList": [1],
|
||||||
|
"generatedCommandList": [],
|
||||||
|
"acceptedCommandList": [],
|
||||||
|
"attributeList": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
|
||||||
|
"featureMap": 0,
|
||||||
|
"clusterRevision": 1,
|
||||||
|
"_type": "chip.clusters.Objects.Descriptor"
|
||||||
|
},
|
||||||
|
"AccessControl": {
|
||||||
|
"acl": [
|
||||||
|
{
|
||||||
|
"privilege": 5,
|
||||||
|
"authMode": 2,
|
||||||
|
"subjects": [1],
|
||||||
|
"targets": null,
|
||||||
|
"fabricIndex": 1,
|
||||||
|
"_type": "chip.clusters.Objects.AccessControl.Structs.AccessControlEntry"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"extension": [],
|
||||||
|
"subjectsPerAccessControlEntry": 4,
|
||||||
|
"targetsPerAccessControlEntry": 3,
|
||||||
|
"accessControlEntriesPerFabric": 3,
|
||||||
|
"generatedCommandList": [],
|
||||||
|
"acceptedCommandList": [],
|
||||||
|
"attributeList": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533],
|
||||||
|
"featureMap": 0,
|
||||||
|
"clusterRevision": 1,
|
||||||
|
"_type": "chip.clusters.Objects.AccessControl"
|
||||||
|
},
|
||||||
|
"Basic": {
|
||||||
|
"dataModelRevision": 1,
|
||||||
|
"vendorName": "Nabu Casa",
|
||||||
|
"vendorID": 65521,
|
||||||
|
"productName": "M5STAMP Lighting App",
|
||||||
|
"productID": 32768,
|
||||||
|
"nodeLabel": "My Cool Light",
|
||||||
|
"location": "XX",
|
||||||
|
"hardwareVersion": 0,
|
||||||
|
"hardwareVersionString": "v1.0",
|
||||||
|
"softwareVersion": 1,
|
||||||
|
"softwareVersionString": "55ab764bea",
|
||||||
|
"manufacturingDate": "20200101",
|
||||||
|
"partNumber": "",
|
||||||
|
"productURL": "",
|
||||||
|
"productLabel": "",
|
||||||
|
"serialNumber": "",
|
||||||
|
"localConfigDisabled": false,
|
||||||
|
"reachable": true,
|
||||||
|
"uniqueID": "BE8F70AA40DDAE41",
|
||||||
|
"capabilityMinima": {
|
||||||
|
"caseSessionsPerFabric": 3,
|
||||||
|
"subscriptionsPerFabric": 65506,
|
||||||
|
"_type": "chip.clusters.Objects.Basic.Structs.CapabilityMinimaStruct"
|
||||||
|
},
|
||||||
|
"generatedCommandList": [],
|
||||||
|
"acceptedCommandList": [],
|
||||||
|
"attributeList": [
|
||||||
|
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19,
|
||||||
|
65528, 65529, 65531, 65532, 65533
|
||||||
|
],
|
||||||
|
"featureMap": 0,
|
||||||
|
"clusterRevision": 1,
|
||||||
|
"_type": "chip.clusters.Objects.Basic"
|
||||||
|
},
|
||||||
|
"OtaSoftwareUpdateRequestor": {
|
||||||
|
"defaultOtaProviders": [],
|
||||||
|
"updatePossible": true,
|
||||||
|
"updateState": 0,
|
||||||
|
"updateStateProgress": 0,
|
||||||
|
"generatedCommandList": [],
|
||||||
|
"acceptedCommandList": [0],
|
||||||
|
"attributeList": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
|
||||||
|
"featureMap": 0,
|
||||||
|
"clusterRevision": 1,
|
||||||
|
"_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor"
|
||||||
|
},
|
||||||
|
"LocalizationConfiguration": {
|
||||||
|
"activeLocale": "en-US",
|
||||||
|
"supportedLocales": [
|
||||||
|
"en-US",
|
||||||
|
"de-DE",
|
||||||
|
"fr-FR",
|
||||||
|
"en-GB",
|
||||||
|
"es-ES",
|
||||||
|
"zh-CN",
|
||||||
|
"it-IT",
|
||||||
|
"ja-JP"
|
||||||
|
],
|
||||||
|
"generatedCommandList": [],
|
||||||
|
"acceptedCommandList": [],
|
||||||
|
"attributeList": [0, 1, 65528, 65529, 65531, 65532, 65533],
|
||||||
|
"featureMap": 0,
|
||||||
|
"clusterRevision": 1,
|
||||||
|
"_type": "chip.clusters.Objects.LocalizationConfiguration"
|
||||||
|
},
|
||||||
|
"TimeFormatLocalization": {
|
||||||
|
"hourFormat": 0,
|
||||||
|
"activeCalendarType": 0,
|
||||||
|
"supportedCalendarTypes": [0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 7],
|
||||||
|
"generatedCommandList": [],
|
||||||
|
"acceptedCommandList": [],
|
||||||
|
"attributeList": [0, 1, 2, 65528, 65529, 65531, 65532, 65533],
|
||||||
|
"featureMap": 0,
|
||||||
|
"clusterRevision": 1,
|
||||||
|
"_type": "chip.clusters.Objects.TimeFormatLocalization"
|
||||||
|
},
|
||||||
|
"GeneralCommissioning": {
|
||||||
|
"breadcrumb": 0,
|
||||||
|
"basicCommissioningInfo": {
|
||||||
|
"failSafeExpiryLengthSeconds": 60,
|
||||||
|
"_type": "chip.clusters.Objects.GeneralCommissioning.Structs.BasicCommissioningInfo"
|
||||||
|
},
|
||||||
|
"regulatoryConfig": 0,
|
||||||
|
"locationCapability": 0,
|
||||||
|
"supportsConcurrentConnection": true,
|
||||||
|
"generatedCommandList": [1, 3, 5],
|
||||||
|
"acceptedCommandList": [0, 2, 4],
|
||||||
|
"attributeList": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533],
|
||||||
|
"featureMap": 6,
|
||||||
|
"clusterRevision": 1,
|
||||||
|
"_type": "chip.clusters.Objects.GeneralCommissioning"
|
||||||
|
},
|
||||||
|
"NetworkCommissioning": {
|
||||||
|
"maxNetworks": 1,
|
||||||
|
"networks": [
|
||||||
|
{
|
||||||
|
"networkID": {
|
||||||
|
"_type": "bytes",
|
||||||
|
"value": "TGF6eUlvVA=="
|
||||||
|
},
|
||||||
|
"connected": true,
|
||||||
|
"_type": "chip.clusters.Objects.NetworkCommissioning.Structs.NetworkInfo"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"scanMaxTimeSeconds": 10,
|
||||||
|
"connectMaxTimeSeconds": 30,
|
||||||
|
"interfaceEnabled": true,
|
||||||
|
"lastNetworkingStatus": 0,
|
||||||
|
"lastNetworkID": {
|
||||||
|
"_type": "bytes",
|
||||||
|
"value": "TGF6eUlvVA=="
|
||||||
|
},
|
||||||
|
"lastConnectErrorValue": null,
|
||||||
|
"generatedCommandList": [1, 5, 7],
|
||||||
|
"acceptedCommandList": [0, 2, 4, 6, 8],
|
||||||
|
"attributeList": [
|
||||||
|
0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533
|
||||||
|
],
|
||||||
|
"featureMap": 1,
|
||||||
|
"clusterRevision": 1,
|
||||||
|
"_type": "chip.clusters.Objects.NetworkCommissioning"
|
||||||
|
},
|
||||||
|
"DiagnosticLogs": {
|
||||||
|
"generatedCommandList": [1],
|
||||||
|
"acceptedCommandList": [0],
|
||||||
|
"attributeList": [65528, 65529, 65531, 65532, 65533],
|
||||||
|
"featureMap": 0,
|
||||||
|
"clusterRevision": 1,
|
||||||
|
"_type": "chip.clusters.Objects.DiagnosticLogs"
|
||||||
|
},
|
||||||
|
"GeneralDiagnostics": {
|
||||||
|
"networkInterfaces": [
|
||||||
|
{
|
||||||
|
"name": "WIFI_AP_DEF",
|
||||||
|
"isOperational": true,
|
||||||
|
"offPremiseServicesReachableIPv4": null,
|
||||||
|
"offPremiseServicesReachableIPv6": null,
|
||||||
|
"hardwareAddress": {
|
||||||
|
"_type": "bytes",
|
||||||
|
"value": "AAAAAAAA"
|
||||||
|
},
|
||||||
|
"IPv4Addresses": [],
|
||||||
|
"IPv6Addresses": [],
|
||||||
|
"type": 1,
|
||||||
|
"_type": "chip.clusters.Objects.GeneralDiagnostics.Structs.NetworkInterfaceType"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "WIFI_STA_DEF",
|
||||||
|
"isOperational": true,
|
||||||
|
"offPremiseServicesReachableIPv4": null,
|
||||||
|
"offPremiseServicesReachableIPv6": null,
|
||||||
|
"hardwareAddress": {
|
||||||
|
"_type": "bytes",
|
||||||
|
"value": "hPcDJ8rI"
|
||||||
|
},
|
||||||
|
"IPv4Addresses": [],
|
||||||
|
"IPv6Addresses": [],
|
||||||
|
"type": 1,
|
||||||
|
"_type": "chip.clusters.Objects.GeneralDiagnostics.Structs.NetworkInterfaceType"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"rebootCount": 12,
|
||||||
|
"upTime": 458,
|
||||||
|
"totalOperationalHours": 0,
|
||||||
|
"bootReasons": 1,
|
||||||
|
"activeHardwareFaults": [],
|
||||||
|
"activeRadioFaults": [],
|
||||||
|
"activeNetworkFaults": [],
|
||||||
|
"testEventTriggersEnabled": false,
|
||||||
|
"generatedCommandList": [],
|
||||||
|
"acceptedCommandList": [0],
|
||||||
|
"attributeList": [
|
||||||
|
0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533
|
||||||
|
],
|
||||||
|
"featureMap": 0,
|
||||||
|
"clusterRevision": 1,
|
||||||
|
"_type": "chip.clusters.Objects.GeneralDiagnostics"
|
||||||
|
},
|
||||||
|
"SoftwareDiagnostics": {
|
||||||
|
"threadMetrics": [],
|
||||||
|
"currentHeapFree": 116140,
|
||||||
|
"currentHeapUsed": 138932,
|
||||||
|
"currentHeapHighWatermark": 153796,
|
||||||
|
"generatedCommandList": [],
|
||||||
|
"acceptedCommandList": [0],
|
||||||
|
"attributeList": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
|
||||||
|
"featureMap": 0,
|
||||||
|
"clusterRevision": 1,
|
||||||
|
"_type": "chip.clusters.Objects.SoftwareDiagnostics"
|
||||||
|
},
|
||||||
|
"ThreadNetworkDiagnostics": {
|
||||||
|
"TLVValue": {
|
||||||
|
"0": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"4": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"5": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"6": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"7": [],
|
||||||
|
"8": [],
|
||||||
|
"9": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"10": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"11": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"12": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"13": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"14": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"15": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"16": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"17": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"18": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"19": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"20": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"21": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"22": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"23": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"24": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"25": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"26": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"27": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"28": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"29": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"30": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"31": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"32": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"33": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"34": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"35": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"36": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"37": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"38": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"39": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"40": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"41": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"42": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"43": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"44": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"45": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"46": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"47": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"48": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"49": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"50": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"51": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"52": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"53": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"54": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"55": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"56": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"57": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"58": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"59": [],
|
||||||
|
"60": {
|
||||||
|
"TLVValue": null,
|
||||||
|
"Reason": "InteractionModelError: Failure (0x1)"
|
||||||
|
},
|
||||||
|
"61": [],
|
||||||
|
"62": [],
|
||||||
|
"65532": 15,
|
||||||
|
"65533": 1,
|
||||||
|
"65528": [],
|
||||||
|
"65529": [0],
|
||||||
|
"65531": [
|
||||||
|
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18,
|
||||||
|
19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35,
|
||||||
|
36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52,
|
||||||
|
53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 65528, 65529, 65531, 65532,
|
||||||
|
65533
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Reason": "Failed to decode field [].channel, expected type <class 'chip.tlv.uint'>, got <class 'chip.clusters.Attribute.ValueDecodeFailure'>"
|
||||||
|
},
|
||||||
|
"WiFiNetworkDiagnostics": {
|
||||||
|
"bssid": {
|
||||||
|
"_type": "bytes",
|
||||||
|
"value": "1iH5ZUbu"
|
||||||
|
},
|
||||||
|
"securityType": 4,
|
||||||
|
"wiFiVersion": 3,
|
||||||
|
"channelNumber": 1,
|
||||||
|
"rssi": -38,
|
||||||
|
"beaconLostCount": 0,
|
||||||
|
"beaconRxCount": 0,
|
||||||
|
"packetMulticastRxCount": 0,
|
||||||
|
"packetMulticastTxCount": 0,
|
||||||
|
"packetUnicastRxCount": 0,
|
||||||
|
"packetUnicastTxCount": 0,
|
||||||
|
"currentMaxRate": 0,
|
||||||
|
"overrunCount": 0,
|
||||||
|
"generatedCommandList": [],
|
||||||
|
"acceptedCommandList": [0],
|
||||||
|
"attributeList": [
|
||||||
|
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 65528, 65529, 65531, 65532,
|
||||||
|
65533
|
||||||
|
],
|
||||||
|
"featureMap": 3,
|
||||||
|
"clusterRevision": 1,
|
||||||
|
"_type": "chip.clusters.Objects.WiFiNetworkDiagnostics"
|
||||||
|
},
|
||||||
|
"EthernetNetworkDiagnostics": {
|
||||||
|
"PHYRate": null,
|
||||||
|
"fullDuplex": null,
|
||||||
|
"packetRxCount": 0,
|
||||||
|
"packetTxCount": 0,
|
||||||
|
"txErrCount": 0,
|
||||||
|
"collisionCount": 0,
|
||||||
|
"overrunCount": 0,
|
||||||
|
"carrierDetect": null,
|
||||||
|
"timeSinceReset": 0,
|
||||||
|
"generatedCommandList": [],
|
||||||
|
"acceptedCommandList": [0],
|
||||||
|
"attributeList": [
|
||||||
|
0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533
|
||||||
|
],
|
||||||
|
"featureMap": 3,
|
||||||
|
"clusterRevision": 1,
|
||||||
|
"_type": "chip.clusters.Objects.EthernetNetworkDiagnostics"
|
||||||
|
},
|
||||||
|
"Switch": {
|
||||||
|
"numberOfPositions": null,
|
||||||
|
"currentPosition": null,
|
||||||
|
"multiPressMax": null,
|
||||||
|
"generatedCommandList": [],
|
||||||
|
"acceptedCommandList": [],
|
||||||
|
"attributeList": [65528, 65529, 65531, 65532, 65533],
|
||||||
|
"featureMap": 0,
|
||||||
|
"clusterRevision": 1,
|
||||||
|
"_type": "chip.clusters.Objects.Switch"
|
||||||
|
},
|
||||||
|
"AdministratorCommissioning": {
|
||||||
|
"windowStatus": 0,
|
||||||
|
"adminFabricIndex": 0,
|
||||||
|
"adminVendorId": 0,
|
||||||
|
"generatedCommandList": [],
|
||||||
|
"acceptedCommandList": [0, 1, 2],
|
||||||
|
"attributeList": [0, 1, 2, 65528, 65529, 65531, 65532, 65533],
|
||||||
|
"featureMap": 0,
|
||||||
|
"clusterRevision": 1,
|
||||||
|
"_type": "chip.clusters.Objects.AdministratorCommissioning"
|
||||||
|
},
|
||||||
|
"OperationalCredentials": {
|
||||||
|
"NOCs": [
|
||||||
|
{
|
||||||
|
"noc": {
|
||||||
|
"_type": "bytes",
|
||||||
|
"value": "FTABAQEkAgE3AyQTARgmBIAigScmBYAlTTo3BiQVASUR8RAYJAcBJAgBMAlBBHQsjZ/8Hpm4iqznEv0dAO03bZx8LDgqpIOpBsHeysZu8KAmI0K+p6B8FuI1h3wld1V+tIj5OHVHtrigg6Ssl043CjUBKAEYJAIBNgMEAgQBGDAEFEWrZiyeoUgEIXz4c40+Nzq9cfxHMAUUSTs2LnMMrX7nj+dns0cSq3SmK3MYMAtAoFdxyvsbLm6VekNCQ6yqJOucAcRSVY3Si4ov1alKPK9CaIPl+u5dvBWNfyEPXSLsPmzyfd2njl8WRz8e7CBiSRg="
|
||||||
|
},
|
||||||
|
"icac": {
|
||||||
|
"_type": "bytes",
|
||||||
|
"value": "FTABAQAkAgE3AyQUABgmBIAigScmBYAlTTo3BiQTARgkBwEkCAEwCUEE09c6S9xVbf3/blpXSgRAZzKXx/4KQC274cEfa2tFjdVAJYJUvM/8PMurRHEroPpA3FXpJ8/hfabkNvHGi2l8tTcKNQEpARgkAmAwBBRJOzYucwytfueP52ezRxKrdKYrczAFFBf0ohq+KHQlEVBIMgEeZCBPR72hGDALQNwd63sOjWKYhjlvDJmcPtIzljSsXlQ10vFrB5j9V9CdiZHDfy537G39fo0RJmpU63EGXYEtXVrEfSMiafshKVcY"
|
||||||
|
},
|
||||||
|
"fabricIndex": 1,
|
||||||
|
"_type": "chip.clusters.Objects.OperationalCredentials.Structs.NOCStruct"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fabrics": [
|
||||||
|
{
|
||||||
|
"rootPublicKey": {
|
||||||
|
"_type": "bytes",
|
||||||
|
"value": "BBGg+O3i3tDVYryXkUmEXk1fnSMHN06+poGIfZODdvbZW4JvxHnrQVAxvZWIE6poLa0sKA8X8A7jmJsVFMUqLFM="
|
||||||
|
},
|
||||||
|
"vendorId": 35328,
|
||||||
|
"fabricId": 1,
|
||||||
|
"nodeId": 4337,
|
||||||
|
"label": "",
|
||||||
|
"fabricIndex": 1,
|
||||||
|
"_type": "chip.clusters.Objects.OperationalCredentials.Structs.FabricDescriptor"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"supportedFabrics": 5,
|
||||||
|
"commissionedFabrics": 1,
|
||||||
|
"trustedRootCertificates": [
|
||||||
|
{
|
||||||
|
"_type": "bytes",
|
||||||
|
"value": "FTABAQAkAgE3AyQUABgmBIAigScmBYAlTTo3BiQUABgkBwEkCAEwCUEEEaD47eLe0NVivJeRSYReTV+dIwc3Tr6mgYh9k4N29tlbgm/EeetBUDG9lYgTqmgtrSwoDxfwDuOYmxUUxSosUzcKNQEpARgkAmAwBBQX9KIavih0JRFQSDIBHmQgT0e9oTAFFBf0ohq+KHQlEVBIMgEeZCBPR72hGDALQO3xFiF2cEXl+/kk0CQfedzHJxSJiziHEjWCMjIj7SVlDVx4CpvNYHnheq+9vJFgcL8JQhAEdz6p6C3INBDL7dsY"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"currentFabricIndex": 1,
|
||||||
|
"generatedCommandList": [1, 3, 5, 8],
|
||||||
|
"acceptedCommandList": [0, 2, 4, 6, 7, 9, 10, 11],
|
||||||
|
"attributeList": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533],
|
||||||
|
"featureMap": 0,
|
||||||
|
"clusterRevision": 1,
|
||||||
|
"_type": "chip.clusters.Objects.OperationalCredentials"
|
||||||
|
},
|
||||||
|
"GroupKeyManagement": {
|
||||||
|
"groupKeyMap": [],
|
||||||
|
"groupTable": [],
|
||||||
|
"maxGroupsPerFabric": 3,
|
||||||
|
"maxGroupKeysPerFabric": 2,
|
||||||
|
"generatedCommandList": [2, 5],
|
||||||
|
"acceptedCommandList": [0, 1, 3, 4],
|
||||||
|
"attributeList": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
|
||||||
|
"featureMap": 0,
|
||||||
|
"clusterRevision": 1,
|
||||||
|
"_type": "chip.clusters.Objects.GroupKeyManagement"
|
||||||
|
},
|
||||||
|
"FixedLabel": {
|
||||||
|
"labelList": [
|
||||||
|
{
|
||||||
|
"label": "room",
|
||||||
|
"value": "bedroom 2",
|
||||||
|
"_type": "chip.clusters.Objects.FixedLabel.Structs.LabelStruct"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "orientation",
|
||||||
|
"value": "North",
|
||||||
|
"_type": "chip.clusters.Objects.FixedLabel.Structs.LabelStruct"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "floor",
|
||||||
|
"value": "2",
|
||||||
|
"_type": "chip.clusters.Objects.FixedLabel.Structs.LabelStruct"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "direction",
|
||||||
|
"value": "up",
|
||||||
|
"_type": "chip.clusters.Objects.FixedLabel.Structs.LabelStruct"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"generatedCommandList": [],
|
||||||
|
"acceptedCommandList": [],
|
||||||
|
"attributeList": [0, 65528, 65529, 65531, 65532, 65533],
|
||||||
|
"featureMap": 0,
|
||||||
|
"clusterRevision": 1,
|
||||||
|
"_type": "chip.clusters.Objects.FixedLabel"
|
||||||
|
},
|
||||||
|
"UserLabel": {
|
||||||
|
"labelList": [],
|
||||||
|
"generatedCommandList": [],
|
||||||
|
"acceptedCommandList": [],
|
||||||
|
"attributeList": [0, 65528, 65529, 65531, 65532, 65533],
|
||||||
|
"featureMap": 0,
|
||||||
|
"clusterRevision": 1,
|
||||||
|
"_type": "chip.clusters.Objects.UserLabel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
"Identify": {
|
||||||
|
"identifyTime": 0,
|
||||||
|
"identifyType": 0,
|
||||||
|
"generatedCommandList": [],
|
||||||
|
"acceptedCommandList": [0, 64],
|
||||||
|
"attributeList": [0, 1, 65528, 65529, 65531, 65532, 65533],
|
||||||
|
"featureMap": 0,
|
||||||
|
"clusterRevision": 4,
|
||||||
|
"_type": "chip.clusters.Objects.Identify"
|
||||||
|
},
|
||||||
|
"Groups": {
|
||||||
|
"nameSupport": 128,
|
||||||
|
"generatedCommandList": [0, 1, 2, 3],
|
||||||
|
"acceptedCommandList": [0, 1, 2, 3, 4, 5],
|
||||||
|
"attributeList": [0, 65528, 65529, 65531, 65532, 65533],
|
||||||
|
"featureMap": 0,
|
||||||
|
"clusterRevision": 4,
|
||||||
|
"_type": "chip.clusters.Objects.Groups"
|
||||||
|
},
|
||||||
|
"OnOff": {
|
||||||
|
"onOff": false,
|
||||||
|
"globalSceneControl": true,
|
||||||
|
"onTime": 0,
|
||||||
|
"offWaitTime": 0,
|
||||||
|
"startUpOnOff": null,
|
||||||
|
"generatedCommandList": [],
|
||||||
|
"acceptedCommandList": [0, 1, 2, 64, 65, 66],
|
||||||
|
"attributeList": [
|
||||||
|
0, 16384, 16385, 16386, 16387, 65528, 65529, 65531, 65532, 65533
|
||||||
|
],
|
||||||
|
"featureMap": 1,
|
||||||
|
"clusterRevision": 4,
|
||||||
|
"_type": "chip.clusters.Objects.OnOff"
|
||||||
|
},
|
||||||
|
"LevelControl": {
|
||||||
|
"currentLevel": 254,
|
||||||
|
"remainingTime": 0,
|
||||||
|
"minLevel": 0,
|
||||||
|
"maxLevel": 254,
|
||||||
|
"currentFrequency": 0,
|
||||||
|
"minFrequency": 0,
|
||||||
|
"maxFrequency": 0,
|
||||||
|
"options": 0,
|
||||||
|
"onOffTransitionTime": 0,
|
||||||
|
"onLevel": null,
|
||||||
|
"onTransitionTime": 0,
|
||||||
|
"offTransitionTime": 0,
|
||||||
|
"defaultMoveRate": 50,
|
||||||
|
"startUpCurrentLevel": null,
|
||||||
|
"generatedCommandList": [],
|
||||||
|
"acceptedCommandList": [0, 1, 2, 3, 4, 5, 6, 7],
|
||||||
|
"attributeList": [
|
||||||
|
0, 1, 2, 3, 4, 5, 6, 15, 16, 17, 18, 19, 20, 16384, 65528, 65529,
|
||||||
|
65531, 65532, 65533
|
||||||
|
],
|
||||||
|
"featureMap": 3,
|
||||||
|
"clusterRevision": 5,
|
||||||
|
"_type": "chip.clusters.Objects.LevelControl"
|
||||||
|
},
|
||||||
|
"Descriptor": {
|
||||||
|
"deviceTypeList": [
|
||||||
|
{
|
||||||
|
"type": 257,
|
||||||
|
"revision": 1,
|
||||||
|
"_type": "chip.clusters.Objects.Descriptor.Structs.DeviceTypeStruct"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"serverList": [3, 4, 6, 8, 29, 768, 1030],
|
||||||
|
"clientList": [],
|
||||||
|
"partsList": [],
|
||||||
|
"generatedCommandList": [],
|
||||||
|
"acceptedCommandList": [],
|
||||||
|
"attributeList": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
|
||||||
|
"featureMap": 0,
|
||||||
|
"clusterRevision": 1,
|
||||||
|
"_type": "chip.clusters.Objects.Descriptor"
|
||||||
|
},
|
||||||
|
"ColorControl": {
|
||||||
|
"currentHue": 0,
|
||||||
|
"currentSaturation": 0,
|
||||||
|
"remainingTime": 0,
|
||||||
|
"currentX": 24939,
|
||||||
|
"currentY": 24701,
|
||||||
|
"driftCompensation": null,
|
||||||
|
"compensationText": null,
|
||||||
|
"colorTemperatureMireds": 0,
|
||||||
|
"colorMode": 2,
|
||||||
|
"options": 0,
|
||||||
|
"numberOfPrimaries": 0,
|
||||||
|
"primary1X": null,
|
||||||
|
"primary1Y": null,
|
||||||
|
"primary1Intensity": null,
|
||||||
|
"primary2X": null,
|
||||||
|
"primary2Y": null,
|
||||||
|
"primary2Intensity": null,
|
||||||
|
"primary3X": null,
|
||||||
|
"primary3Y": null,
|
||||||
|
"primary3Intensity": null,
|
||||||
|
"primary4X": null,
|
||||||
|
"primary4Y": null,
|
||||||
|
"primary4Intensity": null,
|
||||||
|
"primary5X": null,
|
||||||
|
"primary5Y": null,
|
||||||
|
"primary5Intensity": null,
|
||||||
|
"primary6X": null,
|
||||||
|
"primary6Y": null,
|
||||||
|
"primary6Intensity": null,
|
||||||
|
"whitePointX": null,
|
||||||
|
"whitePointY": null,
|
||||||
|
"colorPointRX": null,
|
||||||
|
"colorPointRY": null,
|
||||||
|
"colorPointRIntensity": null,
|
||||||
|
"colorPointGX": null,
|
||||||
|
"colorPointGY": null,
|
||||||
|
"colorPointGIntensity": null,
|
||||||
|
"colorPointBX": null,
|
||||||
|
"colorPointBY": null,
|
||||||
|
"colorPointBIntensity": null,
|
||||||
|
"enhancedCurrentHue": 0,
|
||||||
|
"enhancedColorMode": 2,
|
||||||
|
"colorLoopActive": 0,
|
||||||
|
"colorLoopDirection": 0,
|
||||||
|
"colorLoopTime": 25,
|
||||||
|
"colorLoopStartEnhancedHue": 8960,
|
||||||
|
"colorLoopStoredEnhancedHue": 0,
|
||||||
|
"colorCapabilities": 0,
|
||||||
|
"colorTempPhysicalMinMireds": 0,
|
||||||
|
"colorTempPhysicalMaxMireds": 65279,
|
||||||
|
"coupleColorTempToLevelMinMireds": 0,
|
||||||
|
"startUpColorTemperatureMireds": 0,
|
||||||
|
"generatedCommandList": [],
|
||||||
|
"acceptedCommandList": [
|
||||||
|
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 64, 65, 66, 67, 68, 71, 75, 76
|
||||||
|
],
|
||||||
|
"attributeList": [
|
||||||
|
0, 1, 2, 3, 4, 7, 8, 15, 16, 16384, 16385, 16386, 16387, 16388, 16389,
|
||||||
|
16390, 16394, 16395, 16396, 16397, 16400, 65528, 65529, 65531, 65532,
|
||||||
|
65533
|
||||||
|
],
|
||||||
|
"featureMap": 31,
|
||||||
|
"clusterRevision": 5,
|
||||||
|
"_type": "chip.clusters.Objects.ColorControl"
|
||||||
|
},
|
||||||
|
"OccupancySensing": {
|
||||||
|
"occupancy": 0,
|
||||||
|
"occupancySensorType": 0,
|
||||||
|
"occupancySensorTypeBitmap": 1,
|
||||||
|
"pirOccupiedToUnoccupiedDelay": null,
|
||||||
|
"pirUnoccupiedToOccupiedDelay": null,
|
||||||
|
"pirUnoccupiedToOccupiedThreshold": null,
|
||||||
|
"ultrasonicOccupiedToUnoccupiedDelay": null,
|
||||||
|
"ultrasonicUnoccupiedToOccupiedDelay": null,
|
||||||
|
"ultrasonicUnoccupiedToOccupiedThreshold": null,
|
||||||
|
"physicalContactOccupiedToUnoccupiedDelay": null,
|
||||||
|
"physicalContactUnoccupiedToOccupiedDelay": null,
|
||||||
|
"physicalContactUnoccupiedToOccupiedThreshold": null,
|
||||||
|
"generatedCommandList": [],
|
||||||
|
"acceptedCommandList": [],
|
||||||
|
"attributeList": [0, 1, 2, 65528, 65529, 65531, 65532, 65533],
|
||||||
|
"featureMap": 0,
|
||||||
|
"clusterRevision": 3,
|
||||||
|
"_type": "chip.clusters.Objects.OccupancySensing"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"Header": {
|
||||||
|
"EndpointId": 0,
|
||||||
|
"ClusterId": 40,
|
||||||
|
"EventId": 0,
|
||||||
|
"EventNumber": 262144,
|
||||||
|
"Priority": 2,
|
||||||
|
"Timestamp": 2019,
|
||||||
|
"TimestampType": 0
|
||||||
|
},
|
||||||
|
"Status": 0,
|
||||||
|
"Data": {
|
||||||
|
"softwareVersion": 1,
|
||||||
|
"_type": "chip.clusters.Objects.Basic.Events.StartUp"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Header": {
|
||||||
|
"EndpointId": 0,
|
||||||
|
"ClusterId": 51,
|
||||||
|
"EventId": 3,
|
||||||
|
"EventNumber": 262145,
|
||||||
|
"Priority": 2,
|
||||||
|
"Timestamp": 2020,
|
||||||
|
"TimestampType": 0
|
||||||
|
},
|
||||||
|
"Status": 0,
|
||||||
|
"Data": {
|
||||||
|
"bootReason": 1,
|
||||||
|
"_type": "chip.clusters.Objects.GeneralDiagnostics.Events.BootReason"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Header": {
|
||||||
|
"EndpointId": 0,
|
||||||
|
"ClusterId": 54,
|
||||||
|
"EventId": 2,
|
||||||
|
"EventNumber": 262146,
|
||||||
|
"Priority": 1,
|
||||||
|
"Timestamp": 2216,
|
||||||
|
"TimestampType": 0
|
||||||
|
},
|
||||||
|
"Status": 0,
|
||||||
|
"Data": {
|
||||||
|
"connectionStatus": 0,
|
||||||
|
"_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Events.ConnectionStatus"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"node_id": 4337
|
||||||
|
}
|
78
tests/components/matter/test_adapter.py
Normal file
78
tests/components/matter/test_adapter.py
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
"""Test the adapter."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.components.matter.const import DOMAIN
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import device_registry as dr
|
||||||
|
|
||||||
|
from .common import setup_integration_with_node_fixture
|
||||||
|
|
||||||
|
# TEMP: Tests need to be fixed
|
||||||
|
pytestmark = pytest.mark.skip("all tests still WIP")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_device_registry_single_node_device(
|
||||||
|
hass: HomeAssistant, hass_storage: dict[str, Any]
|
||||||
|
) -> None:
|
||||||
|
"""Test bridge devices are set up correctly with via_device."""
|
||||||
|
await setup_integration_with_node_fixture(
|
||||||
|
hass, hass_storage, "lighting-example-app"
|
||||||
|
)
|
||||||
|
|
||||||
|
dev_reg = dr.async_get(hass)
|
||||||
|
|
||||||
|
entry = dev_reg.async_get_device({(DOMAIN, "BE8F70AA40DDAE41")})
|
||||||
|
assert entry is not None
|
||||||
|
|
||||||
|
assert entry.name == "My Cool Light"
|
||||||
|
assert entry.manufacturer == "Nabu Casa"
|
||||||
|
assert entry.model == "M5STAMP Lighting App"
|
||||||
|
assert entry.hw_version == "v1.0"
|
||||||
|
assert entry.sw_version == "55ab764bea"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_device_registry_bridge(
|
||||||
|
hass: HomeAssistant, hass_storage: dict[str, Any]
|
||||||
|
) -> None:
|
||||||
|
"""Test bridge devices are set up correctly with via_device."""
|
||||||
|
await setup_integration_with_node_fixture(
|
||||||
|
hass, hass_storage, "fake-bridge-two-light"
|
||||||
|
)
|
||||||
|
|
||||||
|
dev_reg = dr.async_get(hass)
|
||||||
|
|
||||||
|
# Validate bridge
|
||||||
|
bridge_entry = dev_reg.async_get_device({(DOMAIN, "mock-hub-id")})
|
||||||
|
assert bridge_entry is not None
|
||||||
|
|
||||||
|
assert bridge_entry.name == "My Mock Bridge"
|
||||||
|
assert bridge_entry.manufacturer == "Mock Vendor"
|
||||||
|
assert bridge_entry.model == "Mock Bridge"
|
||||||
|
assert bridge_entry.hw_version == "TEST_VERSION"
|
||||||
|
assert bridge_entry.sw_version == "123.4.5"
|
||||||
|
|
||||||
|
# Device 1
|
||||||
|
device1_entry = dev_reg.async_get_device({(DOMAIN, "mock-id-kitchen-ceiling")})
|
||||||
|
assert device1_entry is not None
|
||||||
|
|
||||||
|
assert device1_entry.via_device_id == bridge_entry.id
|
||||||
|
assert device1_entry.name == "Kitchen Ceiling"
|
||||||
|
assert device1_entry.manufacturer == "Mock Vendor"
|
||||||
|
assert device1_entry.model == "Mock Light"
|
||||||
|
assert device1_entry.hw_version is None
|
||||||
|
assert device1_entry.sw_version == "67.8.9"
|
||||||
|
|
||||||
|
# Device 2
|
||||||
|
device2_entry = dev_reg.async_get_device({(DOMAIN, "mock-id-living-room-ceiling")})
|
||||||
|
assert device2_entry is not None
|
||||||
|
|
||||||
|
assert device2_entry.via_device_id == bridge_entry.id
|
||||||
|
assert device2_entry.name == "Living Room Ceiling"
|
||||||
|
assert device2_entry.manufacturer == "Mock Vendor"
|
||||||
|
assert device2_entry.model == "Mock Light"
|
||||||
|
assert device2_entry.hw_version is None
|
||||||
|
assert device2_entry.sw_version == "1.49.1"
|
179
tests/components/matter/test_api.py
Normal file
179
tests/components/matter/test_api.py
Normal file
|
@ -0,0 +1,179 @@
|
||||||
|
"""Test the api module."""
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from unittest.mock import MagicMock, call
|
||||||
|
|
||||||
|
from aiohttp import ClientWebSocketResponse
|
||||||
|
from matter_server.client.exceptions import FailedCommand
|
||||||
|
|
||||||
|
from homeassistant.components.matter.api import ID, TYPE
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
|
async def test_commission(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]],
|
||||||
|
matter_client: MagicMock,
|
||||||
|
integration: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test the commission command."""
|
||||||
|
ws_client = await hass_ws_client(hass)
|
||||||
|
|
||||||
|
await ws_client.send_json(
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
TYPE: "matter/commission",
|
||||||
|
"code": "12345678",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
msg = await ws_client.receive_json()
|
||||||
|
|
||||||
|
assert msg["success"]
|
||||||
|
matter_client.commission_with_code.assert_called_once_with("12345678")
|
||||||
|
|
||||||
|
matter_client.commission_with_code.reset_mock()
|
||||||
|
matter_client.commission_with_code.side_effect = FailedCommand(
|
||||||
|
"test_id", "test_code", "Failed to commission"
|
||||||
|
)
|
||||||
|
|
||||||
|
await ws_client.send_json(
|
||||||
|
{
|
||||||
|
ID: 2,
|
||||||
|
TYPE: "matter/commission",
|
||||||
|
"code": "12345678",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
msg = await ws_client.receive_json()
|
||||||
|
|
||||||
|
assert not msg["success"]
|
||||||
|
assert msg["error"]["code"] == "test_code"
|
||||||
|
matter_client.commission_with_code.assert_called_once_with("12345678")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_commission_on_network(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]],
|
||||||
|
matter_client: MagicMock,
|
||||||
|
integration: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test the commission on network command."""
|
||||||
|
ws_client = await hass_ws_client(hass)
|
||||||
|
|
||||||
|
await ws_client.send_json(
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
TYPE: "matter/commission_on_network",
|
||||||
|
"pin": 1234,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
msg = await ws_client.receive_json()
|
||||||
|
|
||||||
|
assert msg["success"]
|
||||||
|
matter_client.commission_on_network.assert_called_once_with(1234)
|
||||||
|
|
||||||
|
matter_client.commission_on_network.reset_mock()
|
||||||
|
matter_client.commission_on_network.side_effect = FailedCommand(
|
||||||
|
"test_id", "test_code", "Failed to commission on network"
|
||||||
|
)
|
||||||
|
|
||||||
|
await ws_client.send_json(
|
||||||
|
{
|
||||||
|
ID: 2,
|
||||||
|
TYPE: "matter/commission_on_network",
|
||||||
|
"pin": 1234,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
msg = await ws_client.receive_json()
|
||||||
|
|
||||||
|
assert not msg["success"]
|
||||||
|
assert msg["error"]["code"] == "test_code"
|
||||||
|
matter_client.commission_on_network.assert_called_once_with(1234)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_set_thread_dataset(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]],
|
||||||
|
matter_client: MagicMock,
|
||||||
|
integration: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test the set thread dataset command."""
|
||||||
|
ws_client = await hass_ws_client(hass)
|
||||||
|
|
||||||
|
await ws_client.send_json(
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
TYPE: "matter/set_thread",
|
||||||
|
"thread_operation_dataset": "test_dataset",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
msg = await ws_client.receive_json()
|
||||||
|
|
||||||
|
assert msg["success"]
|
||||||
|
matter_client.set_thread_operational_dataset.assert_called_once_with("test_dataset")
|
||||||
|
|
||||||
|
matter_client.set_thread_operational_dataset.reset_mock()
|
||||||
|
matter_client.set_thread_operational_dataset.side_effect = FailedCommand(
|
||||||
|
"test_id", "test_code", "Failed to commission"
|
||||||
|
)
|
||||||
|
|
||||||
|
await ws_client.send_json(
|
||||||
|
{
|
||||||
|
ID: 2,
|
||||||
|
TYPE: "matter/set_thread",
|
||||||
|
"thread_operation_dataset": "test_dataset",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
msg = await ws_client.receive_json()
|
||||||
|
|
||||||
|
assert not msg["success"]
|
||||||
|
assert msg["error"]["code"] == "test_code"
|
||||||
|
matter_client.set_thread_operational_dataset.assert_called_once_with("test_dataset")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_set_wifi_credentials(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]],
|
||||||
|
matter_client: MagicMock,
|
||||||
|
integration: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test the set WiFi credentials command."""
|
||||||
|
ws_client = await hass_ws_client(hass)
|
||||||
|
|
||||||
|
await ws_client.send_json(
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
TYPE: "matter/set_wifi_credentials",
|
||||||
|
"network_name": "test_network",
|
||||||
|
"password": "test_password",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
msg = await ws_client.receive_json()
|
||||||
|
|
||||||
|
assert msg["success"]
|
||||||
|
assert matter_client.set_wifi_credentials.call_count == 1
|
||||||
|
assert matter_client.set_wifi_credentials.call_args == call(
|
||||||
|
ssid="test_network", credentials="test_password"
|
||||||
|
)
|
||||||
|
|
||||||
|
matter_client.set_wifi_credentials.reset_mock()
|
||||||
|
matter_client.set_wifi_credentials.side_effect = FailedCommand(
|
||||||
|
"test_id", "test_code", "Failed to commission on network"
|
||||||
|
)
|
||||||
|
|
||||||
|
await ws_client.send_json(
|
||||||
|
{
|
||||||
|
ID: 2,
|
||||||
|
TYPE: "matter/set_wifi_credentials",
|
||||||
|
"network_name": "test_network",
|
||||||
|
"password": "test_password",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
msg = await ws_client.receive_json()
|
||||||
|
|
||||||
|
assert not msg["success"]
|
||||||
|
assert msg["error"]["code"] == "test_code"
|
||||||
|
assert matter_client.set_wifi_credentials.call_count == 1
|
||||||
|
assert matter_client.set_wifi_credentials.call_args == call(
|
||||||
|
ssid="test_network", credentials="test_password"
|
||||||
|
)
|
979
tests/components/matter/test_config_flow.py
Normal file
979
tests/components/matter/test_config_flow.py
Normal file
|
@ -0,0 +1,979 @@
|
||||||
|
"""Test the Matter config flow."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import DEFAULT, AsyncMock, MagicMock, call, patch
|
||||||
|
|
||||||
|
from matter_server.client.exceptions import CannotConnect, InvalidServerVersion
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.components.hassio import HassioAPIError, HassioServiceInfo
|
||||||
|
from homeassistant.components.matter.const import ADDON_SLUG, DOMAIN
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.data_entry_flow import FlowResultType
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
ADDON_DISCOVERY_INFO = {
|
||||||
|
"addon": "Matter Server",
|
||||||
|
"host": "host1",
|
||||||
|
"port": 5581,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="setup_entry", autouse=True)
|
||||||
|
def setup_entry_fixture() -> Generator[AsyncMock, None, None]:
|
||||||
|
"""Mock entry setup."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.matter.async_setup_entry", return_value=True
|
||||||
|
) as mock_setup_entry:
|
||||||
|
yield mock_setup_entry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="client_connect", autouse=True)
|
||||||
|
def client_connect_fixture() -> Generator[AsyncMock, None, None]:
|
||||||
|
"""Mock server version."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.matter.config_flow.MatterClient.connect"
|
||||||
|
) as client_connect:
|
||||||
|
yield client_connect
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="supervisor")
|
||||||
|
def supervisor_fixture() -> Generator[MagicMock, None, None]:
|
||||||
|
"""Mock Supervisor."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.matter.config_flow.is_hassio", return_value=True
|
||||||
|
) as is_hassio:
|
||||||
|
yield is_hassio
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="discovery_info")
|
||||||
|
def discovery_info_fixture() -> Any:
|
||||||
|
"""Return the discovery info from the supervisor."""
|
||||||
|
return DEFAULT
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="get_addon_discovery_info", autouse=True)
|
||||||
|
def get_addon_discovery_info_fixture(
|
||||||
|
discovery_info: Any,
|
||||||
|
) -> Generator[AsyncMock, None, None]:
|
||||||
|
"""Mock get add-on discovery info."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.hassio.addon_manager.async_get_addon_discovery_info",
|
||||||
|
return_value=discovery_info,
|
||||||
|
) as get_addon_discovery_info:
|
||||||
|
yield get_addon_discovery_info
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="addon_setup_time", autouse=True)
|
||||||
|
def addon_setup_time_fixture() -> Generator[int, None, None]:
|
||||||
|
"""Mock add-on setup sleep time."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.matter.config_flow.ADDON_SETUP_TIMEOUT", new=0
|
||||||
|
) as addon_setup_time:
|
||||||
|
yield addon_setup_time
|
||||||
|
|
||||||
|
|
||||||
|
async def test_manual_create_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
client_connect: AsyncMock,
|
||||||
|
setup_entry: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test user step create entry."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
assert result["type"] == FlowResultType.FORM
|
||||||
|
assert result["errors"] is None
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{
|
||||||
|
"url": "ws://localhost:5580/ws",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert client_connect.call_count == 1
|
||||||
|
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||||
|
assert result["title"] == "ws://localhost:5580/ws"
|
||||||
|
assert result["data"] == {
|
||||||
|
"url": "ws://localhost:5580/ws",
|
||||||
|
"integration_created_addon": False,
|
||||||
|
"use_addon": False,
|
||||||
|
}
|
||||||
|
assert setup_entry.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"error, side_effect",
|
||||||
|
[
|
||||||
|
("cannot_connect", CannotConnect(Exception("Boom"))),
|
||||||
|
("invalid_server_version", InvalidServerVersion("Invalid version")),
|
||||||
|
("unknown", Exception("Unknown boom")),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_manual_errors(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
client_connect: AsyncMock,
|
||||||
|
error: str,
|
||||||
|
side_effect: Exception,
|
||||||
|
) -> None:
|
||||||
|
"""Test user step cannot connect error."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
client_connect.side_effect = side_effect
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{
|
||||||
|
"url": "ws://localhost:5580/ws",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert client_connect.call_count == 1
|
||||||
|
assert result["type"] == FlowResultType.FORM
|
||||||
|
assert result["errors"] == {"base": error}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_manual_already_configured(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
client_connect: AsyncMock,
|
||||||
|
setup_entry: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test manual step abort if already configured."""
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN, data={"url": "ws://host1:5581/ws"}, title="Matter"
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == FlowResultType.FORM
|
||||||
|
assert result["errors"] is None
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{
|
||||||
|
"url": "ws://localhost:5580/ws",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert client_connect.call_count == 1
|
||||||
|
assert result["type"] == FlowResultType.ABORT
|
||||||
|
assert result["reason"] == "reconfiguration_successful"
|
||||||
|
assert entry.data["url"] == "ws://localhost:5580/ws"
|
||||||
|
assert entry.data["use_addon"] is False
|
||||||
|
assert entry.data["integration_created_addon"] is False
|
||||||
|
assert entry.title == "ws://localhost:5580/ws"
|
||||||
|
assert setup_entry.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}])
|
||||||
|
async def test_supervisor_discovery(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
supervisor: MagicMock,
|
||||||
|
addon_running: AsyncMock,
|
||||||
|
addon_info: AsyncMock,
|
||||||
|
client_connect: AsyncMock,
|
||||||
|
setup_entry: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test flow started from Supervisor discovery."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": config_entries.SOURCE_HASSIO},
|
||||||
|
data=HassioServiceInfo(
|
||||||
|
config=ADDON_DISCOVERY_INFO,
|
||||||
|
name="Matter Server",
|
||||||
|
slug=ADDON_SLUG,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert addon_info.call_count == 1
|
||||||
|
assert client_connect.call_count == 0
|
||||||
|
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||||
|
assert result["title"] == "ws://host1:5581/ws"
|
||||||
|
assert result["data"] == {
|
||||||
|
"url": "ws://host1:5581/ws",
|
||||||
|
"use_addon": True,
|
||||||
|
"integration_created_addon": False,
|
||||||
|
}
|
||||||
|
assert setup_entry.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"discovery_info, error",
|
||||||
|
[({"config": ADDON_DISCOVERY_INFO}, HassioAPIError())],
|
||||||
|
)
|
||||||
|
async def test_supervisor_discovery_addon_info_failed(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
supervisor: MagicMock,
|
||||||
|
addon_running: AsyncMock,
|
||||||
|
addon_info: AsyncMock,
|
||||||
|
error: Exception,
|
||||||
|
) -> None:
|
||||||
|
"""Test Supervisor discovery and addon info failed."""
|
||||||
|
addon_info.side_effect = error
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": config_entries.SOURCE_HASSIO},
|
||||||
|
data=HassioServiceInfo(
|
||||||
|
config=ADDON_DISCOVERY_INFO,
|
||||||
|
name="Matter Server",
|
||||||
|
slug=ADDON_SLUG,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == FlowResultType.FORM
|
||||||
|
assert result["step_id"] == "hassio_confirm"
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
||||||
|
|
||||||
|
assert addon_info.call_count == 1
|
||||||
|
assert result["type"] == FlowResultType.ABORT
|
||||||
|
assert result["reason"] == "addon_info_failed"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}])
|
||||||
|
async def test_clean_supervisor_discovery_on_user_create(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
supervisor: MagicMock,
|
||||||
|
addon_running: AsyncMock,
|
||||||
|
addon_info: AsyncMock,
|
||||||
|
client_connect: AsyncMock,
|
||||||
|
setup_entry: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test discovery flow is cleaned up when a user flow is finished."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": config_entries.SOURCE_HASSIO},
|
||||||
|
data=HassioServiceInfo(
|
||||||
|
config=ADDON_DISCOVERY_INFO,
|
||||||
|
name="Matter Server",
|
||||||
|
slug=ADDON_SLUG,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == FlowResultType.FORM
|
||||||
|
assert result["step_id"] == "hassio_confirm"
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == FlowResultType.FORM
|
||||||
|
assert result["step_id"] == "on_supervisor"
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], {"use_addon": False}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert addon_info.call_count == 0
|
||||||
|
assert result["type"] == FlowResultType.FORM
|
||||||
|
assert result["step_id"] == "manual"
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{
|
||||||
|
"url": "ws://localhost:5580/ws",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert len(hass.config_entries.flow.async_progress()) == 0
|
||||||
|
assert client_connect.call_count == 1
|
||||||
|
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||||
|
assert result["title"] == "ws://localhost:5580/ws"
|
||||||
|
assert result["data"] == {
|
||||||
|
"url": "ws://localhost:5580/ws",
|
||||||
|
"use_addon": False,
|
||||||
|
"integration_created_addon": False,
|
||||||
|
}
|
||||||
|
assert setup_entry.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_abort_supervisor_discovery_with_existing_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
supervisor: MagicMock,
|
||||||
|
addon_running: AsyncMock,
|
||||||
|
addon_info: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test discovery flow is aborted if an entry already exists."""
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
data={"url": "ws://localhost:5580/ws"},
|
||||||
|
title="ws://localhost:5580/ws",
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": config_entries.SOURCE_HASSIO},
|
||||||
|
data=HassioServiceInfo(
|
||||||
|
config=ADDON_DISCOVERY_INFO,
|
||||||
|
name="Matter Server",
|
||||||
|
slug=ADDON_SLUG,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert addon_info.call_count == 0
|
||||||
|
assert result["type"] == FlowResultType.ABORT
|
||||||
|
assert result["reason"] == "already_configured"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_abort_supervisor_discovery_with_existing_flow(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
supervisor: MagicMock,
|
||||||
|
addon_installed: AsyncMock,
|
||||||
|
addon_info: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test hassio discovery flow is aborted when another flow is in progress."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": config_entries.SOURCE_USER},
|
||||||
|
)
|
||||||
|
assert result["type"] == FlowResultType.FORM
|
||||||
|
assert result["step_id"] == "on_supervisor"
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": config_entries.SOURCE_HASSIO},
|
||||||
|
data=HassioServiceInfo(
|
||||||
|
config=ADDON_DISCOVERY_INFO,
|
||||||
|
name="Matter Server",
|
||||||
|
slug=ADDON_SLUG,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert addon_info.call_count == 0
|
||||||
|
assert result["type"] == FlowResultType.ABORT
|
||||||
|
assert result["reason"] == "already_in_progress"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_abort_supervisor_discovery_for_other_addon(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
supervisor: MagicMock,
|
||||||
|
addon_installed: AsyncMock,
|
||||||
|
addon_info: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test hassio discovery flow is aborted for a non official add-on discovery."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": config_entries.SOURCE_HASSIO},
|
||||||
|
data=HassioServiceInfo(
|
||||||
|
config={
|
||||||
|
"addon": "Other Matter Server",
|
||||||
|
"host": "host1",
|
||||||
|
"port": 3001,
|
||||||
|
},
|
||||||
|
name="Other Matter Server",
|
||||||
|
slug="other_addon",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert addon_info.call_count == 0
|
||||||
|
assert result["type"] == FlowResultType.ABORT
|
||||||
|
assert result["reason"] == "not_matter_addon"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_supervisor_discovery_addon_not_running(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
supervisor: MagicMock,
|
||||||
|
addon_installed: AsyncMock,
|
||||||
|
addon_info: AsyncMock,
|
||||||
|
start_addon: AsyncMock,
|
||||||
|
client_connect: AsyncMock,
|
||||||
|
setup_entry: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test discovery with add-on already installed but not running."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": config_entries.SOURCE_HASSIO},
|
||||||
|
data=HassioServiceInfo(
|
||||||
|
config=ADDON_DISCOVERY_INFO,
|
||||||
|
name="Matter Server",
|
||||||
|
slug=ADDON_SLUG,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert addon_info.call_count == 0
|
||||||
|
assert result["step_id"] == "hassio_confirm"
|
||||||
|
assert result["type"] == FlowResultType.FORM
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
||||||
|
|
||||||
|
assert addon_info.call_count == 1
|
||||||
|
assert result["type"] == FlowResultType.SHOW_PROGRESS
|
||||||
|
assert result["step_id"] == "start_addon"
|
||||||
|
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert start_addon.call_args == call(hass, "core_matter_server")
|
||||||
|
assert client_connect.call_count == 1
|
||||||
|
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||||
|
assert result["title"] == "ws://host1:5581/ws"
|
||||||
|
assert result["data"] == {
|
||||||
|
"url": "ws://host1:5581/ws",
|
||||||
|
"use_addon": True,
|
||||||
|
"integration_created_addon": False,
|
||||||
|
}
|
||||||
|
assert setup_entry.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_supervisor_discovery_addon_not_installed(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
supervisor: MagicMock,
|
||||||
|
addon_not_installed: AsyncMock,
|
||||||
|
install_addon: AsyncMock,
|
||||||
|
addon_info: AsyncMock,
|
||||||
|
addon_store_info: AsyncMock,
|
||||||
|
start_addon: AsyncMock,
|
||||||
|
client_connect: AsyncMock,
|
||||||
|
setup_entry: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test discovery with add-on not installed."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": config_entries.SOURCE_HASSIO},
|
||||||
|
data=HassioServiceInfo(
|
||||||
|
config=ADDON_DISCOVERY_INFO,
|
||||||
|
name="Matter Server",
|
||||||
|
slug=ADDON_SLUG,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert addon_info.call_count == 0
|
||||||
|
assert addon_store_info.call_count == 0
|
||||||
|
assert result["step_id"] == "hassio_confirm"
|
||||||
|
assert result["type"] == FlowResultType.FORM
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
||||||
|
|
||||||
|
assert addon_info.call_count == 0
|
||||||
|
assert addon_store_info.call_count == 1
|
||||||
|
assert result["step_id"] == "install_addon"
|
||||||
|
assert result["type"] == FlowResultType.SHOW_PROGRESS
|
||||||
|
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||||
|
|
||||||
|
assert install_addon.call_args == call(hass, "core_matter_server")
|
||||||
|
assert result["type"] == FlowResultType.SHOW_PROGRESS
|
||||||
|
assert result["step_id"] == "start_addon"
|
||||||
|
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert start_addon.call_args == call(hass, "core_matter_server")
|
||||||
|
assert client_connect.call_count == 1
|
||||||
|
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||||
|
assert result["title"] == "ws://host1:5581/ws"
|
||||||
|
assert result["data"] == {
|
||||||
|
"url": "ws://host1:5581/ws",
|
||||||
|
"use_addon": True,
|
||||||
|
"integration_created_addon": True,
|
||||||
|
}
|
||||||
|
assert setup_entry.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_not_addon(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
supervisor: MagicMock,
|
||||||
|
client_connect: AsyncMock,
|
||||||
|
setup_entry: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test opting out of add-on on Supervisor."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == FlowResultType.FORM
|
||||||
|
assert result["step_id"] == "on_supervisor"
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], {"use_addon": False}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == FlowResultType.FORM
|
||||||
|
assert result["step_id"] == "manual"
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{
|
||||||
|
"url": "ws://localhost:5581/ws",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert client_connect.call_count == 1
|
||||||
|
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||||
|
assert result["title"] == "ws://localhost:5581/ws"
|
||||||
|
assert result["data"] == {
|
||||||
|
"url": "ws://localhost:5581/ws",
|
||||||
|
"use_addon": False,
|
||||||
|
"integration_created_addon": False,
|
||||||
|
}
|
||||||
|
assert setup_entry.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}])
|
||||||
|
async def test_addon_running(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
supervisor: MagicMock,
|
||||||
|
addon_running: AsyncMock,
|
||||||
|
addon_info: AsyncMock,
|
||||||
|
client_connect: AsyncMock,
|
||||||
|
setup_entry: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test add-on already running on Supervisor."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == FlowResultType.FORM
|
||||||
|
assert result["step_id"] == "on_supervisor"
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], {"use_addon": True}
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert addon_info.call_count == 1
|
||||||
|
assert client_connect.call_count == 1
|
||||||
|
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||||
|
assert result["title"] == "ws://host1:5581/ws"
|
||||||
|
assert result["data"] == {
|
||||||
|
"url": "ws://host1:5581/ws",
|
||||||
|
"use_addon": True,
|
||||||
|
"integration_created_addon": False,
|
||||||
|
}
|
||||||
|
assert setup_entry.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"discovery_info, discovery_info_error, client_connect_error, addon_info_error, "
|
||||||
|
"abort_reason, discovery_info_called, client_connect_called",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
{"config": ADDON_DISCOVERY_INFO},
|
||||||
|
HassioAPIError(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"addon_get_discovery_info_failed",
|
||||||
|
True,
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"config": ADDON_DISCOVERY_INFO},
|
||||||
|
None,
|
||||||
|
CannotConnect(Exception("Boom")),
|
||||||
|
None,
|
||||||
|
"cannot_connect",
|
||||||
|
True,
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"addon_get_discovery_info_failed",
|
||||||
|
True,
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"config": ADDON_DISCOVERY_INFO},
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
HassioAPIError(),
|
||||||
|
"addon_info_failed",
|
||||||
|
False,
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_addon_running_failures(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
supervisor: MagicMock,
|
||||||
|
addon_running: AsyncMock,
|
||||||
|
addon_info: AsyncMock,
|
||||||
|
get_addon_discovery_info: AsyncMock,
|
||||||
|
client_connect: AsyncMock,
|
||||||
|
discovery_info_error: Exception | None,
|
||||||
|
client_connect_error: Exception | None,
|
||||||
|
addon_info_error: Exception | None,
|
||||||
|
abort_reason: str,
|
||||||
|
discovery_info_called: bool,
|
||||||
|
client_connect_called: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Test all failures when add-on is running."""
|
||||||
|
get_addon_discovery_info.side_effect = discovery_info_error
|
||||||
|
client_connect.side_effect = client_connect_error
|
||||||
|
addon_info.side_effect = addon_info_error
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == FlowResultType.FORM
|
||||||
|
assert result["step_id"] == "on_supervisor"
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], {"use_addon": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert addon_info.call_count == 1
|
||||||
|
assert get_addon_discovery_info.called is discovery_info_called
|
||||||
|
assert client_connect.called is client_connect_called
|
||||||
|
assert result["type"] == FlowResultType.ABORT
|
||||||
|
assert result["reason"] == abort_reason
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}])
|
||||||
|
async def test_addon_running_already_configured(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
supervisor: MagicMock,
|
||||||
|
addon_running: AsyncMock,
|
||||||
|
addon_info: AsyncMock,
|
||||||
|
setup_entry: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test that only one instance is allowed when add-on is running."""
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
data={
|
||||||
|
"url": "ws://localhost:5580/ws",
|
||||||
|
},
|
||||||
|
title="ws://localhost:5580/ws",
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == FlowResultType.FORM
|
||||||
|
assert result["step_id"] == "on_supervisor"
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], {"use_addon": True}
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert addon_info.call_count == 1
|
||||||
|
assert result["type"] == FlowResultType.ABORT
|
||||||
|
assert result["reason"] == "reconfiguration_successful"
|
||||||
|
assert entry.data["url"] == "ws://host1:5581/ws"
|
||||||
|
assert entry.title == "ws://host1:5581/ws"
|
||||||
|
assert setup_entry.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}])
|
||||||
|
async def test_addon_installed(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
supervisor: MagicMock,
|
||||||
|
addon_installed: AsyncMock,
|
||||||
|
addon_info: AsyncMock,
|
||||||
|
start_addon: AsyncMock,
|
||||||
|
setup_entry: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test add-on already installed but not running on Supervisor."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == FlowResultType.FORM
|
||||||
|
assert result["step_id"] == "on_supervisor"
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], {"use_addon": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert addon_info.call_count == 1
|
||||||
|
assert result["type"] == FlowResultType.SHOW_PROGRESS
|
||||||
|
assert result["step_id"] == "start_addon"
|
||||||
|
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert start_addon.call_args == call(hass, "core_matter_server")
|
||||||
|
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||||
|
assert result["title"] == "ws://host1:5581/ws"
|
||||||
|
assert result["data"] == {
|
||||||
|
"url": "ws://host1:5581/ws",
|
||||||
|
"use_addon": True,
|
||||||
|
"integration_created_addon": False,
|
||||||
|
}
|
||||||
|
assert setup_entry.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"discovery_info, start_addon_error, client_connect_error, "
|
||||||
|
"discovery_info_called, client_connect_called",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
{"config": ADDON_DISCOVERY_INFO},
|
||||||
|
HassioAPIError(),
|
||||||
|
None,
|
||||||
|
False,
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"config": ADDON_DISCOVERY_INFO},
|
||||||
|
None,
|
||||||
|
CannotConnect(Exception("Boom")),
|
||||||
|
True,
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
True,
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_addon_installed_failures(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
supervisor: MagicMock,
|
||||||
|
addon_installed: AsyncMock,
|
||||||
|
addon_info: AsyncMock,
|
||||||
|
start_addon: AsyncMock,
|
||||||
|
get_addon_discovery_info: AsyncMock,
|
||||||
|
client_connect: AsyncMock,
|
||||||
|
start_addon_error: Exception | None,
|
||||||
|
client_connect_error: Exception | None,
|
||||||
|
discovery_info_called: bool,
|
||||||
|
client_connect_called: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Test add-on start failure when add-on is installed."""
|
||||||
|
start_addon.side_effect = start_addon_error
|
||||||
|
client_connect.side_effect = client_connect_error
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == FlowResultType.FORM
|
||||||
|
assert result["step_id"] == "on_supervisor"
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], {"use_addon": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert addon_info.call_count == 1
|
||||||
|
assert result["type"] == FlowResultType.SHOW_PROGRESS
|
||||||
|
assert result["step_id"] == "start_addon"
|
||||||
|
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||||
|
|
||||||
|
assert start_addon.call_args == call(hass, "core_matter_server")
|
||||||
|
assert get_addon_discovery_info.called is discovery_info_called
|
||||||
|
assert client_connect.called is client_connect_called
|
||||||
|
assert result["type"] == FlowResultType.ABORT
|
||||||
|
assert result["reason"] == "addon_start_failed"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}])
|
||||||
|
async def test_addon_installed_already_configured(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
supervisor: MagicMock,
|
||||||
|
addon_installed: AsyncMock,
|
||||||
|
addon_info: AsyncMock,
|
||||||
|
start_addon: AsyncMock,
|
||||||
|
setup_entry: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test that only one instance is allowed when add-on is installed."""
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
data={
|
||||||
|
"url": "ws://localhost:5580/ws",
|
||||||
|
},
|
||||||
|
title="ws://localhost:5580/ws",
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == FlowResultType.FORM
|
||||||
|
assert result["step_id"] == "on_supervisor"
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], {"use_addon": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert addon_info.call_count == 1
|
||||||
|
assert result["type"] == FlowResultType.SHOW_PROGRESS
|
||||||
|
assert result["step_id"] == "start_addon"
|
||||||
|
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert start_addon.call_args == call(hass, "core_matter_server")
|
||||||
|
assert result["type"] == FlowResultType.ABORT
|
||||||
|
assert result["reason"] == "reconfiguration_successful"
|
||||||
|
assert entry.data["url"] == "ws://host1:5581/ws"
|
||||||
|
assert entry.title == "ws://host1:5581/ws"
|
||||||
|
assert setup_entry.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}])
|
||||||
|
async def test_addon_not_installed(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
supervisor: MagicMock,
|
||||||
|
addon_not_installed: AsyncMock,
|
||||||
|
install_addon: AsyncMock,
|
||||||
|
addon_info: AsyncMock,
|
||||||
|
addon_store_info: AsyncMock,
|
||||||
|
start_addon: AsyncMock,
|
||||||
|
setup_entry: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test add-on not installed."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == FlowResultType.FORM
|
||||||
|
assert result["step_id"] == "on_supervisor"
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], {"use_addon": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert addon_info.call_count == 0
|
||||||
|
assert addon_store_info.call_count == 1
|
||||||
|
assert result["type"] == FlowResultType.SHOW_PROGRESS
|
||||||
|
assert result["step_id"] == "install_addon"
|
||||||
|
|
||||||
|
# Make sure the flow continues when the progress task is done.
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||||
|
|
||||||
|
assert install_addon.call_args == call(hass, "core_matter_server")
|
||||||
|
assert result["type"] == FlowResultType.SHOW_PROGRESS
|
||||||
|
assert result["step_id"] == "start_addon"
|
||||||
|
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert start_addon.call_args == call(hass, "core_matter_server")
|
||||||
|
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||||
|
assert result["title"] == "ws://host1:5581/ws"
|
||||||
|
assert result["data"] == {
|
||||||
|
"url": "ws://host1:5581/ws",
|
||||||
|
"use_addon": True,
|
||||||
|
"integration_created_addon": True,
|
||||||
|
}
|
||||||
|
assert setup_entry.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_addon_not_installed_failures(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
supervisor: MagicMock,
|
||||||
|
addon_not_installed: AsyncMock,
|
||||||
|
addon_info: AsyncMock,
|
||||||
|
install_addon: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test add-on install failure."""
|
||||||
|
install_addon.side_effect = HassioAPIError()
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == FlowResultType.FORM
|
||||||
|
assert result["step_id"] == "on_supervisor"
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], {"use_addon": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == FlowResultType.SHOW_PROGRESS
|
||||||
|
assert result["step_id"] == "install_addon"
|
||||||
|
|
||||||
|
# Make sure the flow continues when the progress task is done.
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||||
|
|
||||||
|
assert install_addon.call_args == call(hass, "core_matter_server")
|
||||||
|
assert addon_info.call_count == 0
|
||||||
|
assert result["type"] == FlowResultType.ABORT
|
||||||
|
assert result["reason"] == "addon_install_failed"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}])
|
||||||
|
async def test_addon_not_installed_already_configured(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
supervisor: MagicMock,
|
||||||
|
addon_not_installed: AsyncMock,
|
||||||
|
addon_info: AsyncMock,
|
||||||
|
addon_store_info: AsyncMock,
|
||||||
|
install_addon: AsyncMock,
|
||||||
|
start_addon: AsyncMock,
|
||||||
|
client_connect: AsyncMock,
|
||||||
|
setup_entry: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test that only one instance is allowed when add-on is not installed."""
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
data={
|
||||||
|
"url": "ws://localhost:5580/ws",
|
||||||
|
},
|
||||||
|
title="ws://localhost:5580/ws",
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == FlowResultType.FORM
|
||||||
|
assert result["step_id"] == "on_supervisor"
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], {"use_addon": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert addon_info.call_count == 0
|
||||||
|
assert addon_store_info.call_count == 1
|
||||||
|
assert result["type"] == FlowResultType.SHOW_PROGRESS
|
||||||
|
assert result["step_id"] == "install_addon"
|
||||||
|
|
||||||
|
# Make sure the flow continues when the progress task is done.
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||||
|
|
||||||
|
assert install_addon.call_args == call(hass, "core_matter_server")
|
||||||
|
assert result["type"] == FlowResultType.SHOW_PROGRESS
|
||||||
|
assert result["step_id"] == "start_addon"
|
||||||
|
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert start_addon.call_args == call(hass, "core_matter_server")
|
||||||
|
assert client_connect.call_count == 1
|
||||||
|
assert result["type"] == FlowResultType.ABORT
|
||||||
|
assert result["reason"] == "reconfiguration_successful"
|
||||||
|
assert entry.data["url"] == "ws://host1:5581/ws"
|
||||||
|
assert entry.title == "ws://host1:5581/ws"
|
||||||
|
assert setup_entry.call_count == 1
|
398
tests/components/matter/test_init.py
Normal file
398
tests/components/matter/test_init.py
Normal file
|
@ -0,0 +1,398 @@
|
||||||
|
"""Test the Matter integration init."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, call
|
||||||
|
|
||||||
|
from matter_server.client.exceptions import InvalidServerVersion
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.components.hassio import HassioAPIError
|
||||||
|
from homeassistant.components.matter.const import DOMAIN
|
||||||
|
from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import issue_registry as ir
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
|
async def test_raise_addon_task_in_progress(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
addon_not_installed: AsyncMock,
|
||||||
|
install_addon: AsyncMock,
|
||||||
|
start_addon: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test raise ConfigEntryNotReady if an add-on task is in progress."""
|
||||||
|
install_event = asyncio.Event()
|
||||||
|
|
||||||
|
install_addon_original_side_effect = install_addon.side_effect
|
||||||
|
|
||||||
|
async def install_addon_side_effect(hass: HomeAssistant, slug: str) -> None:
|
||||||
|
"""Mock install add-on."""
|
||||||
|
await install_event.wait()
|
||||||
|
await install_addon_original_side_effect(hass, slug)
|
||||||
|
|
||||||
|
install_addon.side_effect = install_addon_side_effect
|
||||||
|
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
title="Matter",
|
||||||
|
data={
|
||||||
|
"url": "ws://host1:5581/ws",
|
||||||
|
"use_addon": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
await hass.config_entries.async_setup(entry.entry_id)
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
assert entry.state is ConfigEntryState.SETUP_RETRY
|
||||||
|
assert install_addon.call_count == 1
|
||||||
|
assert start_addon.call_count == 0
|
||||||
|
|
||||||
|
# Check that we only call install add-on once if a task is in progress.
|
||||||
|
await hass.config_entries.async_reload(entry.entry_id)
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
assert entry.state is ConfigEntryState.SETUP_RETRY
|
||||||
|
assert install_addon.call_count == 1
|
||||||
|
assert start_addon.call_count == 0
|
||||||
|
|
||||||
|
install_event.set()
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert install_addon.call_count == 1
|
||||||
|
assert start_addon.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_start_addon(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
addon_installed: AsyncMock,
|
||||||
|
addon_info: AsyncMock,
|
||||||
|
install_addon: AsyncMock,
|
||||||
|
start_addon: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test start the Matter Server add-on during entry setup."""
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
title="Matter",
|
||||||
|
data={
|
||||||
|
"url": "ws://host1:5581/ws",
|
||||||
|
"use_addon": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
await hass.config_entries.async_setup(entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert entry.state is ConfigEntryState.SETUP_RETRY
|
||||||
|
assert addon_info.call_count == 1
|
||||||
|
assert install_addon.call_count == 0
|
||||||
|
assert start_addon.call_count == 1
|
||||||
|
assert start_addon.call_args == call(hass, "core_matter_server")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_install_addon(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
addon_not_installed: AsyncMock,
|
||||||
|
addon_store_info: AsyncMock,
|
||||||
|
install_addon: AsyncMock,
|
||||||
|
start_addon: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test install and start the Matter add-on during entry setup."""
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
title="Matter",
|
||||||
|
data={
|
||||||
|
"url": "ws://host1:5581/ws",
|
||||||
|
"use_addon": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
await hass.config_entries.async_setup(entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert entry.state is ConfigEntryState.SETUP_RETRY
|
||||||
|
assert addon_store_info.call_count == 2
|
||||||
|
assert install_addon.call_count == 1
|
||||||
|
assert install_addon.call_args == call(hass, "core_matter_server")
|
||||||
|
assert start_addon.call_count == 1
|
||||||
|
assert start_addon.call_args == call(hass, "core_matter_server")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_addon_info_failure(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
addon_installed: AsyncMock,
|
||||||
|
addon_info: AsyncMock,
|
||||||
|
install_addon: AsyncMock,
|
||||||
|
start_addon: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test failure to get add-on info for Matter add-on during entry setup."""
|
||||||
|
addon_info.side_effect = HassioAPIError("Boom")
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
title="Matter",
|
||||||
|
data={
|
||||||
|
"url": "ws://host1:5581/ws",
|
||||||
|
"use_addon": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
await hass.config_entries.async_setup(entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert entry.state is ConfigEntryState.SETUP_RETRY
|
||||||
|
assert addon_info.call_count == 1
|
||||||
|
assert install_addon.call_count == 0
|
||||||
|
assert start_addon.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"addon_version, update_available, update_calls, backup_calls, "
|
||||||
|
"update_addon_side_effect, create_backup_side_effect",
|
||||||
|
[
|
||||||
|
("1.0.0", True, 1, 1, None, None),
|
||||||
|
("1.0.0", False, 0, 0, None, None),
|
||||||
|
("1.0.0", True, 1, 1, HassioAPIError("Boom"), None),
|
||||||
|
("1.0.0", True, 0, 1, None, HassioAPIError("Boom")),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_update_addon(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
addon_installed: AsyncMock,
|
||||||
|
addon_running: AsyncMock,
|
||||||
|
addon_info: AsyncMock,
|
||||||
|
install_addon: AsyncMock,
|
||||||
|
start_addon: AsyncMock,
|
||||||
|
create_backup: AsyncMock,
|
||||||
|
update_addon: AsyncMock,
|
||||||
|
matter_client: MagicMock,
|
||||||
|
addon_version: str,
|
||||||
|
update_available: bool,
|
||||||
|
update_calls: int,
|
||||||
|
backup_calls: int,
|
||||||
|
update_addon_side_effect: Exception | None,
|
||||||
|
create_backup_side_effect: Exception | None,
|
||||||
|
):
|
||||||
|
"""Test update the Matter add-on during entry setup."""
|
||||||
|
addon_info.return_value["version"] = addon_version
|
||||||
|
addon_info.return_value["update_available"] = update_available
|
||||||
|
create_backup.side_effect = create_backup_side_effect
|
||||||
|
update_addon.side_effect = update_addon_side_effect
|
||||||
|
matter_client.connect.side_effect = InvalidServerVersion("Invalid version")
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
title="Matter",
|
||||||
|
data={
|
||||||
|
"url": "ws://host1:5581/ws",
|
||||||
|
"use_addon": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
await hass.config_entries.async_setup(entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert entry.state is ConfigEntryState.SETUP_RETRY
|
||||||
|
assert create_backup.call_count == backup_calls
|
||||||
|
assert update_addon.call_count == update_calls
|
||||||
|
|
||||||
|
|
||||||
|
async def test_issue_registry_invalid_version(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
matter_client: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test issue registry for invalid version."""
|
||||||
|
original_connect_side_effect = matter_client.connect.side_effect
|
||||||
|
matter_client.connect.side_effect = InvalidServerVersion("Invalid version")
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
title="Matter",
|
||||||
|
data={
|
||||||
|
"url": "ws://host1:5581/ws",
|
||||||
|
"use_addon": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
await hass.config_entries.async_setup(entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
issue_reg = ir.async_get(hass)
|
||||||
|
entry_state = entry.state
|
||||||
|
assert entry_state is ConfigEntryState.SETUP_RETRY
|
||||||
|
assert issue_reg.async_get_issue(DOMAIN, "invalid_server_version")
|
||||||
|
|
||||||
|
matter_client.connect.side_effect = original_connect_side_effect
|
||||||
|
|
||||||
|
await hass.config_entries.async_reload(entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert entry.state is ConfigEntryState.LOADED
|
||||||
|
assert not issue_reg.async_get_issue(DOMAIN, "invalid_server_version")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"stop_addon_side_effect, entry_state",
|
||||||
|
[
|
||||||
|
(None, ConfigEntryState.NOT_LOADED),
|
||||||
|
(HassioAPIError("Boom"), ConfigEntryState.LOADED),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_stop_addon(
|
||||||
|
hass,
|
||||||
|
matter_client: MagicMock,
|
||||||
|
addon_installed: AsyncMock,
|
||||||
|
addon_running: AsyncMock,
|
||||||
|
addon_info: AsyncMock,
|
||||||
|
stop_addon: AsyncMock,
|
||||||
|
stop_addon_side_effect: Exception | None,
|
||||||
|
entry_state: ConfigEntryState,
|
||||||
|
):
|
||||||
|
"""Test stop the Matter add-on on entry unload if entry is disabled."""
|
||||||
|
stop_addon.side_effect = stop_addon_side_effect
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
title="Matter",
|
||||||
|
data={
|
||||||
|
"url": "ws://host1:5581/ws",
|
||||||
|
"use_addon": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
await hass.config_entries.async_setup(entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert entry.state is ConfigEntryState.LOADED
|
||||||
|
assert addon_info.call_count == 1
|
||||||
|
addon_info.reset_mock()
|
||||||
|
|
||||||
|
await hass.config_entries.async_set_disabled_by(
|
||||||
|
entry.entry_id, ConfigEntryDisabler.USER
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert entry.state == entry_state
|
||||||
|
assert stop_addon.call_count == 1
|
||||||
|
assert stop_addon.call_args == call(hass, "core_matter_server")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_remove_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
addon_installed: AsyncMock,
|
||||||
|
stop_addon: AsyncMock,
|
||||||
|
create_backup: AsyncMock,
|
||||||
|
uninstall_addon: AsyncMock,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Test remove the config entry."""
|
||||||
|
# test successful remove without created add-on
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
title="Matter",
|
||||||
|
data={"integration_created_addon": False},
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
assert entry.state is ConfigEntryState.NOT_LOADED
|
||||||
|
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||||
|
|
||||||
|
await hass.config_entries.async_remove(entry.entry_id)
|
||||||
|
|
||||||
|
assert entry.state is ConfigEntryState.NOT_LOADED
|
||||||
|
assert len(hass.config_entries.async_entries(DOMAIN)) == 0
|
||||||
|
|
||||||
|
# test successful remove with created add-on
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
title="Matter",
|
||||||
|
data={"integration_created_addon": True},
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||||
|
|
||||||
|
await hass.config_entries.async_remove(entry.entry_id)
|
||||||
|
|
||||||
|
assert stop_addon.call_count == 1
|
||||||
|
assert stop_addon.call_args == call(hass, "core_matter_server")
|
||||||
|
assert create_backup.call_count == 1
|
||||||
|
assert create_backup.call_args == call(
|
||||||
|
hass,
|
||||||
|
{"name": "addon_core_matter_server_1.0.0", "addons": ["core_matter_server"]},
|
||||||
|
partial=True,
|
||||||
|
)
|
||||||
|
assert uninstall_addon.call_count == 1
|
||||||
|
assert uninstall_addon.call_args == call(hass, "core_matter_server")
|
||||||
|
assert entry.state is ConfigEntryState.NOT_LOADED
|
||||||
|
assert len(hass.config_entries.async_entries(DOMAIN)) == 0
|
||||||
|
stop_addon.reset_mock()
|
||||||
|
create_backup.reset_mock()
|
||||||
|
uninstall_addon.reset_mock()
|
||||||
|
|
||||||
|
# test add-on stop failure
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||||
|
stop_addon.side_effect = HassioAPIError()
|
||||||
|
|
||||||
|
await hass.config_entries.async_remove(entry.entry_id)
|
||||||
|
|
||||||
|
assert stop_addon.call_count == 1
|
||||||
|
assert stop_addon.call_args == call(hass, "core_matter_server")
|
||||||
|
assert create_backup.call_count == 0
|
||||||
|
assert uninstall_addon.call_count == 0
|
||||||
|
assert entry.state is ConfigEntryState.NOT_LOADED
|
||||||
|
assert len(hass.config_entries.async_entries(DOMAIN)) == 0
|
||||||
|
assert "Failed to stop the Matter Server add-on" in caplog.text
|
||||||
|
stop_addon.side_effect = None
|
||||||
|
stop_addon.reset_mock()
|
||||||
|
create_backup.reset_mock()
|
||||||
|
uninstall_addon.reset_mock()
|
||||||
|
|
||||||
|
# test create backup failure
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||||
|
create_backup.side_effect = HassioAPIError()
|
||||||
|
|
||||||
|
await hass.config_entries.async_remove(entry.entry_id)
|
||||||
|
|
||||||
|
assert stop_addon.call_count == 1
|
||||||
|
assert stop_addon.call_args == call(hass, "core_matter_server")
|
||||||
|
assert create_backup.call_count == 1
|
||||||
|
assert create_backup.call_args == call(
|
||||||
|
hass,
|
||||||
|
{"name": "addon_core_matter_server_1.0.0", "addons": ["core_matter_server"]},
|
||||||
|
partial=True,
|
||||||
|
)
|
||||||
|
assert uninstall_addon.call_count == 0
|
||||||
|
assert entry.state is ConfigEntryState.NOT_LOADED
|
||||||
|
assert len(hass.config_entries.async_entries(DOMAIN)) == 0
|
||||||
|
assert "Failed to create a backup of the Matter Server add-on" in caplog.text
|
||||||
|
create_backup.side_effect = None
|
||||||
|
stop_addon.reset_mock()
|
||||||
|
create_backup.reset_mock()
|
||||||
|
uninstall_addon.reset_mock()
|
||||||
|
|
||||||
|
# test add-on uninstall failure
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||||
|
uninstall_addon.side_effect = HassioAPIError()
|
||||||
|
|
||||||
|
await hass.config_entries.async_remove(entry.entry_id)
|
||||||
|
|
||||||
|
assert stop_addon.call_count == 1
|
||||||
|
assert stop_addon.call_args == call(hass, "core_matter_server")
|
||||||
|
assert create_backup.call_count == 1
|
||||||
|
assert create_backup.call_args == call(
|
||||||
|
hass,
|
||||||
|
{"name": "addon_core_matter_server_1.0.0", "addons": ["core_matter_server"]},
|
||||||
|
partial=True,
|
||||||
|
)
|
||||||
|
assert uninstall_addon.call_count == 1
|
||||||
|
assert uninstall_addon.call_args == call(hass, "core_matter_server")
|
||||||
|
assert entry.state is ConfigEntryState.NOT_LOADED
|
||||||
|
assert len(hass.config_entries.async_entries(DOMAIN)) == 0
|
||||||
|
assert "Failed to uninstall the Matter Server add-on" in caplog.text
|
82
tests/components/matter/test_light.py
Normal file
82
tests/components/matter/test_light.py
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
"""Test Matter lights."""
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from chip.clusters import Objects as clusters
|
||||||
|
from matter_server.common.models.node import MatterNode
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from .common import setup_integration_with_node_fixture
|
||||||
|
|
||||||
|
# TEMP: Tests need to be fixed
|
||||||
|
pytestmark = pytest.mark.skip("all tests still WIP")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="light_node")
|
||||||
|
async def light_node_fixture(
|
||||||
|
hass: HomeAssistant, hass_storage: dict[str, Any]
|
||||||
|
) -> MatterNode:
|
||||||
|
"""Fixture for a light node."""
|
||||||
|
return await setup_integration_with_node_fixture(
|
||||||
|
hass, hass_storage, "lighting-example-app"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_turn_on(hass: HomeAssistant, light_node: MatterNode) -> None:
|
||||||
|
"""Test turning on a light."""
|
||||||
|
light_node.matter.client.mock_command(clusters.OnOff.Commands.On, None)
|
||||||
|
|
||||||
|
await hass.services.async_call(
|
||||||
|
"light",
|
||||||
|
"turn_on",
|
||||||
|
{
|
||||||
|
"entity_id": "light.my_cool_light",
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(light_node.matter.client.mock_sent_commands) == 1
|
||||||
|
args = light_node.matter.client.mock_sent_commands[0]
|
||||||
|
assert args["nodeid"] == light_node.node_id
|
||||||
|
assert args["endpoint"] == 1
|
||||||
|
|
||||||
|
light_node.matter.client.mock_command(
|
||||||
|
clusters.LevelControl.Commands.MoveToLevelWithOnOff, None
|
||||||
|
)
|
||||||
|
|
||||||
|
await hass.services.async_call(
|
||||||
|
"light",
|
||||||
|
"turn_on",
|
||||||
|
{
|
||||||
|
"entity_id": "light.my_cool_light",
|
||||||
|
"brightness": 128,
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(light_node.matter.client.mock_sent_commands) == 2
|
||||||
|
args = light_node.matter.client.mock_sent_commands[1]
|
||||||
|
assert args["nodeid"] == light_node.node_id
|
||||||
|
assert args["endpoint"] == 1
|
||||||
|
assert args["payload"].level == 127
|
||||||
|
assert args["payload"].transitionTime == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_turn_off(hass: HomeAssistant, light_node: MatterNode) -> None:
|
||||||
|
"""Test turning off a light."""
|
||||||
|
light_node.matter.client.mock_command(clusters.OnOff.Commands.Off, None)
|
||||||
|
|
||||||
|
await hass.services.async_call(
|
||||||
|
"light",
|
||||||
|
"turn_off",
|
||||||
|
{
|
||||||
|
"entity_id": "light.my_cool_light",
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(light_node.matter.client.mock_sent_commands) == 1
|
||||||
|
args = light_node.matter.client.mock_sent_commands[0]
|
||||||
|
assert args["nodeid"] == light_node.node_id
|
||||||
|
assert args["endpoint"] == 1
|
Loading…
Add table
Reference in a new issue