mirror of
https://github.com/home-assistant/core.git
synced 2026-03-12 14:02:03 +01:00
Compare commits
5 Commits
scop-actio
...
use-availa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87c56c3a53 | ||
|
|
6498298d56 | ||
|
|
c1acd1d860 | ||
|
|
f4748aa63d | ||
|
|
31f4f618cc |
2
.github/workflows/builder.yml
vendored
2
.github/workflows/builder.yml
vendored
@@ -614,7 +614,7 @@ jobs:
|
||||
|
||||
- name: Generate artifact attestation
|
||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
||||
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
|
||||
with:
|
||||
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
|
||||
@@ -179,7 +179,9 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
|
||||
return PRESET_HOLIDAY
|
||||
if self.data.summer_active:
|
||||
return PRESET_SUMMER
|
||||
if self.data.target_temperature == ON_API_TEMPERATURE:
|
||||
if self.data.target_temperature == ON_API_TEMPERATURE or getattr(
|
||||
self.data, "boost_active", False
|
||||
):
|
||||
return PRESET_BOOST
|
||||
if self.data.target_temperature == self.data.comfort_temperature:
|
||||
return PRESET_COMFORT
|
||||
|
||||
@@ -10,7 +10,11 @@ from functools import partial, wraps
|
||||
import logging
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from aiohasupervisor import SupervisorError
|
||||
from aiohasupervisor import (
|
||||
AddonNotSupportedError,
|
||||
SupervisorError,
|
||||
SupervisorNotFoundError,
|
||||
)
|
||||
from aiohasupervisor.models import (
|
||||
AddonsOptions,
|
||||
AddonState as SupervisorAddonState,
|
||||
@@ -165,15 +169,7 @@ class AddonManager:
|
||||
)
|
||||
|
||||
addon_info = await self._supervisor_client.addons.addon_info(self.addon_slug)
|
||||
addon_state = self.async_get_addon_state(addon_info)
|
||||
return AddonInfo(
|
||||
available=addon_info.available,
|
||||
hostname=addon_info.hostname,
|
||||
options=addon_info.options,
|
||||
state=addon_state,
|
||||
update_available=addon_info.update_available,
|
||||
version=addon_info.version,
|
||||
)
|
||||
return self.async_convert_installed_app_info(addon_info)
|
||||
|
||||
@callback
|
||||
def async_get_addon_state(self, addon_info: InstalledAddonComplete) -> AddonState:
|
||||
@@ -189,6 +185,20 @@ class AddonManager:
|
||||
|
||||
return addon_state
|
||||
|
||||
@callback
|
||||
def async_convert_installed_app_info(
|
||||
self, app_info: InstalledAddonComplete
|
||||
) -> AddonInfo:
|
||||
"""Convert InstalledAddonComplete model to AddonInfo model."""
|
||||
return AddonInfo(
|
||||
available=app_info.available,
|
||||
hostname=app_info.hostname,
|
||||
options=app_info.options,
|
||||
state=self.async_get_addon_state(app_info),
|
||||
update_available=app_info.update_available,
|
||||
version=app_info.version,
|
||||
)
|
||||
|
||||
@api_error(
|
||||
"Failed to set the {addon_name} app options",
|
||||
expected_error_type=SupervisorError,
|
||||
@@ -199,21 +209,17 @@ class AddonManager:
|
||||
self.addon_slug, AddonsOptions(config=config)
|
||||
)
|
||||
|
||||
def _check_addon_available(self, addon_info: AddonInfo) -> None:
|
||||
"""Check if the managed add-on is available."""
|
||||
if not addon_info.available:
|
||||
raise AddonError(f"{self.addon_name} app is not available")
|
||||
|
||||
@api_error(
|
||||
"Failed to install the {addon_name} app", expected_error_type=SupervisorError
|
||||
)
|
||||
async def async_install_addon(self) -> None:
|
||||
"""Install the managed add-on."""
|
||||
addon_info = await self.async_get_addon_info()
|
||||
|
||||
self._check_addon_available(addon_info)
|
||||
|
||||
await self._supervisor_client.store.install_addon(self.addon_slug)
|
||||
try:
|
||||
await self._supervisor_client.store.install_addon(self.addon_slug)
|
||||
except AddonNotSupportedError as err:
|
||||
raise AddonError(
|
||||
f"{self.addon_name} app is not available: {err!s}"
|
||||
) from None
|
||||
|
||||
@api_error(
|
||||
"Failed to uninstall the {addon_name} app",
|
||||
@@ -226,17 +232,24 @@ class AddonManager:
|
||||
@api_error("Failed to update the {addon_name} app")
|
||||
async def async_update_addon(self) -> None:
|
||||
"""Update the managed add-on if needed."""
|
||||
addon_info = await self.async_get_addon_info()
|
||||
try:
|
||||
app_info = await self._supervisor_client.addons.addon_info(self.addon_slug)
|
||||
except SupervisorNotFoundError:
|
||||
raise AddonError(f"{self.addon_name} app is not installed") from None
|
||||
|
||||
self._check_addon_available(addon_info)
|
||||
|
||||
if addon_info.state is AddonState.NOT_INSTALLED:
|
||||
raise AddonError(f"{self.addon_name} app is not installed")
|
||||
|
||||
if not addon_info.update_available:
|
||||
if not app_info.update_available:
|
||||
return
|
||||
|
||||
await self.async_create_backup()
|
||||
try:
|
||||
await self._supervisor_client.store.addon_availability(self.addon_slug)
|
||||
except AddonNotSupportedError as err:
|
||||
raise AddonError(
|
||||
f"{self.addon_name} app is not available: {err!s}"
|
||||
) from None
|
||||
|
||||
await self.async_create_backup(
|
||||
app_info=self.async_convert_installed_app_info(app_info)
|
||||
)
|
||||
await self._supervisor_client.store.update_addon(
|
||||
self.addon_slug, StoreAddonUpdate(backup=False)
|
||||
)
|
||||
@@ -266,10 +279,14 @@ class AddonManager:
|
||||
"Failed to create a backup of the {addon_name} app",
|
||||
expected_error_type=SupervisorError,
|
||||
)
|
||||
async def async_create_backup(self) -> None:
|
||||
async def async_create_backup(self, *, app_info: AddonInfo | None = None) -> None:
|
||||
"""Create a partial backup of the managed add-on."""
|
||||
addon_info = await self.async_get_addon_info()
|
||||
name = f"addon_{self.addon_slug}_{addon_info.version}"
|
||||
if app_info:
|
||||
app_version = app_info.version
|
||||
else:
|
||||
app_version = (await self.async_get_addon_info()).version
|
||||
|
||||
name = f"addon_{self.addon_slug}_{app_version}"
|
||||
|
||||
self._logger.debug("Creating backup: %s", name)
|
||||
await self._supervisor_client.backups.partial_backup(
|
||||
|
||||
@@ -72,6 +72,7 @@ ABBREVIATIONS = {
|
||||
"fan_mode_stat_t": "fan_mode_state_topic",
|
||||
"frc_upd": "force_update",
|
||||
"g_tpl": "green_template",
|
||||
"grp": "group",
|
||||
"hs_cmd_t": "hs_command_topic",
|
||||
"hs_cmd_tpl": "hs_command_template",
|
||||
"hs_stat_t": "hs_state_topic",
|
||||
|
||||
@@ -10,6 +10,7 @@ from homeassistant.helpers import config_validation as cv
|
||||
from .const import (
|
||||
CONF_COMMAND_TOPIC,
|
||||
CONF_ENCODING,
|
||||
CONF_GROUP,
|
||||
CONF_QOS,
|
||||
CONF_RETAIN,
|
||||
CONF_STATE_TOPIC,
|
||||
@@ -23,6 +24,7 @@ from .util import valid_publish_topic, valid_qos_schema, valid_subscribe_topic
|
||||
SCHEMA_BASE = {
|
||||
vol.Optional(CONF_QOS, default=DEFAULT_QOS): valid_qos_schema,
|
||||
vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string,
|
||||
vol.Optional(CONF_GROUP): vol.All(cv.ensure_list, [cv.string]),
|
||||
}
|
||||
|
||||
MQTT_BASE_SCHEMA = vol.Schema(SCHEMA_BASE)
|
||||
|
||||
@@ -109,6 +109,7 @@ CONF_FLASH_TIME_SHORT = "flash_time_short"
|
||||
CONF_GET_POSITION_TEMPLATE = "position_template"
|
||||
CONF_GET_POSITION_TOPIC = "position_topic"
|
||||
CONF_GREEN_TEMPLATE = "green_template"
|
||||
CONF_GROUP = "group"
|
||||
CONF_HS_COMMAND_TEMPLATE = "hs_command_template"
|
||||
CONF_HS_COMMAND_TOPIC = "hs_command_topic"
|
||||
CONF_HS_STATE_TOPIC = "hs_state_topic"
|
||||
|
||||
@@ -48,6 +48,7 @@ from homeassistant.helpers.event import (
|
||||
async_track_device_registry_updated_event,
|
||||
async_track_entity_registry_updated_event,
|
||||
)
|
||||
from homeassistant.helpers.group import IntegrationSpecificGroup
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
|
||||
from homeassistant.helpers.typing import (
|
||||
@@ -78,6 +79,7 @@ from .const import (
|
||||
CONF_ENABLED_BY_DEFAULT,
|
||||
CONF_ENCODING,
|
||||
CONF_ENTITY_PICTURE,
|
||||
CONF_GROUP,
|
||||
CONF_HW_VERSION,
|
||||
CONF_IDENTIFIERS,
|
||||
CONF_JSON_ATTRS_TEMPLATE,
|
||||
@@ -133,6 +135,7 @@ MQTT_ATTRIBUTES_BLOCKED = {
|
||||
"device_class",
|
||||
"device_info",
|
||||
"entity_category",
|
||||
"entity_id",
|
||||
"entity_picture",
|
||||
"entity_registry_enabled_default",
|
||||
"extra_state_attributes",
|
||||
@@ -460,7 +463,7 @@ def async_setup_entity_entry_helper(
|
||||
|
||||
|
||||
class MqttAttributesMixin(Entity):
|
||||
"""Mixin used for platforms that support JSON attributes."""
|
||||
"""Mixin used for platforms that support JSON attributes and group entities."""
|
||||
|
||||
_attributes_extra_blocked: frozenset[str] = frozenset()
|
||||
_attr_tpl: Callable[[ReceivePayloadType], ReceivePayloadType] | None = None
|
||||
@@ -468,10 +471,13 @@ class MqttAttributesMixin(Entity):
|
||||
[MessageCallbackType, set[str] | None, ReceiveMessage], None
|
||||
]
|
||||
_process_update_extra_state_attributes: Callable[[dict[str, Any]], None]
|
||||
group: IntegrationSpecificGroup | None
|
||||
|
||||
def __init__(self, config: ConfigType) -> None:
|
||||
"""Initialize the JSON attributes mixin."""
|
||||
"""Initialize the JSON attributes and handle group entities."""
|
||||
self._attributes_sub_state: dict[str, EntitySubscription] = {}
|
||||
if CONF_GROUP in config:
|
||||
self.group = IntegrationSpecificGroup(self, config[CONF_GROUP])
|
||||
self._attributes_config = config
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
@@ -482,6 +488,16 @@ class MqttAttributesMixin(Entity):
|
||||
|
||||
def attributes_prepare_discovery_update(self, config: DiscoveryInfoType) -> None:
|
||||
"""Handle updated discovery message."""
|
||||
if CONF_GROUP in config:
|
||||
if self.group is not None:
|
||||
self.group.member_unique_ids = config[CONF_GROUP]
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"Group member update received for entity %s, "
|
||||
"but this entity was not initialized with the `group` option. "
|
||||
"Reload the MQTT integration or restart Home Assistant to activate"
|
||||
)
|
||||
|
||||
self._attributes_config = config
|
||||
self._attributes_prepare_subscribe_topics()
|
||||
|
||||
@@ -543,7 +559,7 @@ class MqttAttributesMixin(Entity):
|
||||
_LOGGER.warning("Erroneous JSON: %s", payload)
|
||||
else:
|
||||
if isinstance(json_dict, dict):
|
||||
filtered_dict = {
|
||||
filtered_dict: dict[str, Any] = {
|
||||
k: v
|
||||
for k, v in json_dict.items()
|
||||
if k not in MQTT_ATTRIBUTES_BLOCKED
|
||||
|
||||
@@ -217,9 +217,6 @@
|
||||
"energy_left": {
|
||||
"default": "mdi:battery"
|
||||
},
|
||||
"energy_remaining": {
|
||||
"default": "mdi:battery-medium"
|
||||
},
|
||||
"generator_power": {
|
||||
"default": "mdi:generator-stationary"
|
||||
},
|
||||
|
||||
@@ -299,14 +299,6 @@ BATTERY_DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = (
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
TessieSensorEntityDescription(
|
||||
key="energy_remaining",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY_STORAGE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
TessieSensorEntityDescription(
|
||||
key="lifetime_energy_used",
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
|
||||
@@ -458,9 +458,6 @@
|
||||
"energy_left": {
|
||||
"name": "Energy left"
|
||||
},
|
||||
"energy_remaining": {
|
||||
"name": "Energy remaining"
|
||||
},
|
||||
"generator_energy_exported": {
|
||||
"name": "Generator exported"
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@ import string
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from aiohasupervisor import SupervisorNotFoundError
|
||||
from aiohasupervisor.models import (
|
||||
Discovery,
|
||||
GreenInfo,
|
||||
@@ -313,6 +314,7 @@ def addon_not_installed_fixture(
|
||||
"""Mock add-on not installed."""
|
||||
from .hassio.common import mock_addon_not_installed # noqa: PLC0415
|
||||
|
||||
addon_info.side_effect = SupervisorNotFoundError
|
||||
return mock_addon_not_installed(addon_store_info, addon_info)
|
||||
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@ class FritzDeviceClimateMock(FritzEntityBaseMock):
|
||||
has_thermostat = True
|
||||
has_blind = False
|
||||
holiday_active = False
|
||||
boost_active = False
|
||||
lock = "fake_locked"
|
||||
present = True
|
||||
summer_active = False
|
||||
|
||||
@@ -442,7 +442,7 @@ async def test_preset_mode_update(hass: HomeAssistant, fritz: Mock) -> None:
|
||||
assert state
|
||||
assert state.attributes[ATTR_PRESET_MODE] == PRESET_ECO
|
||||
|
||||
# test boost preset
|
||||
# test boost preset by special temp
|
||||
device.target_temperature = 127 # special temp from the api
|
||||
next_update = dt_util.utcnow() + timedelta(seconds=200)
|
||||
async_fire_time_changed(hass, next_update)
|
||||
@@ -453,6 +453,18 @@ async def test_preset_mode_update(hass: HomeAssistant, fritz: Mock) -> None:
|
||||
assert state
|
||||
assert state.attributes[ATTR_PRESET_MODE] == PRESET_BOOST
|
||||
|
||||
# test boost preset by boost_active
|
||||
device.target_temperature = 21
|
||||
device.boost_active = True
|
||||
next_update = dt_util.utcnow() + timedelta(seconds=200)
|
||||
async_fire_time_changed(hass, next_update)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
|
||||
assert fritz().update_devices.call_count == 5
|
||||
assert state
|
||||
assert state.attributes[ATTR_PRESET_MODE] == PRESET_BOOST
|
||||
|
||||
|
||||
async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None:
|
||||
"""Test adding new discovered devices during runtime."""
|
||||
|
||||
@@ -3,11 +3,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, call
|
||||
from uuid import uuid4
|
||||
|
||||
from aiohasupervisor import SupervisorError
|
||||
from aiohasupervisor import (
|
||||
AddonNotSupportedArchitectureError,
|
||||
AddonNotSupportedHomeAssistantVersionError,
|
||||
AddonNotSupportedMachineTypeError,
|
||||
SupervisorError,
|
||||
)
|
||||
from aiohasupervisor.models import AddonsOptions, Discovery, PartialBackupOptions
|
||||
import pytest
|
||||
|
||||
@@ -20,10 +24,8 @@ from homeassistant.components.hassio.addon_manager import (
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
|
||||
async def test_not_installed_raises_exception(
|
||||
addon_manager: AddonManager,
|
||||
addon_not_installed: dict[str, Any],
|
||||
) -> None:
|
||||
@pytest.mark.usefixtures("addon_not_installed")
|
||||
async def test_not_installed_raises_exception(addon_manager: AddonManager) -> None:
|
||||
"""Test addon not installed raises exception."""
|
||||
addon_config = {"test_key": "test"}
|
||||
|
||||
@@ -38,24 +40,40 @@ async def test_not_installed_raises_exception(
|
||||
assert str(err.value) == "Test app is not installed"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exception",
|
||||
[
|
||||
AddonNotSupportedArchitectureError(
|
||||
"Add-on test not supported on this platform, supported architectures: test"
|
||||
),
|
||||
AddonNotSupportedHomeAssistantVersionError(
|
||||
"Add-on test not supported on this system, requires Home Assistant version 2026.1.0 or greater"
|
||||
),
|
||||
AddonNotSupportedMachineTypeError(
|
||||
"Add-on test not supported on this machine, supported machine types: test"
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_not_available_raises_exception(
|
||||
addon_manager: AddonManager,
|
||||
addon_store_info: AsyncMock,
|
||||
supervisor_client: AsyncMock,
|
||||
addon_info: AsyncMock,
|
||||
exception: SupervisorError,
|
||||
) -> None:
|
||||
"""Test addon not available raises exception."""
|
||||
addon_store_info.return_value.available = False
|
||||
addon_info.return_value.available = False
|
||||
supervisor_client.store.addon_availability.side_effect = exception
|
||||
supervisor_client.store.install_addon.side_effect = exception
|
||||
addon_info.return_value.update_available = True
|
||||
|
||||
with pytest.raises(AddonError) as err:
|
||||
await addon_manager.async_install_addon()
|
||||
|
||||
assert str(err.value) == "Test app is not available"
|
||||
assert str(err.value) == f"Test app is not available: {exception!s}"
|
||||
|
||||
with pytest.raises(AddonError) as err:
|
||||
await addon_manager.async_update_addon()
|
||||
|
||||
assert str(err.value) == "Test app is not available"
|
||||
assert str(err.value) == f"Test app is not available: {exception!s}"
|
||||
|
||||
|
||||
async def test_get_addon_discovery_info(
|
||||
@@ -496,11 +514,10 @@ async def test_stop_addon_error(
|
||||
assert stop_addon.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("hass", "addon_installed")
|
||||
async def test_update_addon(
|
||||
hass: HomeAssistant,
|
||||
addon_manager: AddonManager,
|
||||
addon_info: AsyncMock,
|
||||
addon_installed: AsyncMock,
|
||||
create_backup: AsyncMock,
|
||||
update_addon: AsyncMock,
|
||||
) -> None:
|
||||
@@ -509,7 +526,7 @@ async def test_update_addon(
|
||||
|
||||
await addon_manager.async_update_addon()
|
||||
|
||||
assert addon_info.call_count == 2
|
||||
assert addon_info.call_count == 1
|
||||
assert create_backup.call_count == 1
|
||||
assert create_backup.call_args == call(
|
||||
PartialBackupOptions(name="addon_test_addon_1.0.0", addons={"test_addon"})
|
||||
@@ -517,10 +534,10 @@ async def test_update_addon(
|
||||
assert update_addon.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("addon_installed")
|
||||
async def test_update_addon_no_update(
|
||||
addon_manager: AddonManager,
|
||||
addon_info: AsyncMock,
|
||||
addon_installed: AsyncMock,
|
||||
create_backup: AsyncMock,
|
||||
update_addon: AsyncMock,
|
||||
) -> None:
|
||||
@@ -534,11 +551,10 @@ async def test_update_addon_no_update(
|
||||
assert update_addon.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("hass", "addon_installed")
|
||||
async def test_update_addon_error(
|
||||
hass: HomeAssistant,
|
||||
addon_manager: AddonManager,
|
||||
addon_info: AsyncMock,
|
||||
addon_installed: AsyncMock,
|
||||
create_backup: AsyncMock,
|
||||
update_addon: AsyncMock,
|
||||
) -> None:
|
||||
@@ -551,7 +567,7 @@ async def test_update_addon_error(
|
||||
|
||||
assert str(err.value) == "Failed to update the Test app: Boom"
|
||||
|
||||
assert addon_info.call_count == 2
|
||||
assert addon_info.call_count == 1
|
||||
assert create_backup.call_count == 1
|
||||
assert create_backup.call_args == call(
|
||||
PartialBackupOptions(name="addon_test_addon_1.0.0", addons={"test_addon"})
|
||||
@@ -559,11 +575,10 @@ async def test_update_addon_error(
|
||||
assert update_addon.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("hass", "addon_installed")
|
||||
async def test_schedule_update_addon(
|
||||
hass: HomeAssistant,
|
||||
addon_manager: AddonManager,
|
||||
addon_info: AsyncMock,
|
||||
addon_installed: AsyncMock,
|
||||
create_backup: AsyncMock,
|
||||
update_addon: AsyncMock,
|
||||
) -> None:
|
||||
@@ -589,7 +604,7 @@ async def test_schedule_update_addon(
|
||||
await asyncio.gather(update_task, update_task_two)
|
||||
|
||||
assert addon_manager.task_in_progress() is False
|
||||
assert addon_info.call_count == 3
|
||||
assert addon_info.call_count == 2
|
||||
assert create_backup.call_count == 1
|
||||
assert create_backup.call_args == call(
|
||||
PartialBackupOptions(name="addon_test_addon_1.0.0", addons={"test_addon"})
|
||||
|
||||
@@ -906,7 +906,6 @@ async def test_config_flow_thread_addon_already_installed(
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("addon_not_installed")
|
||||
async def test_options_flow_zigbee_to_thread(
|
||||
hass: HomeAssistant,
|
||||
install_addon: AsyncMock,
|
||||
|
||||
@@ -445,17 +445,15 @@ async def test_zeroconf_not_onboarded_installed(
|
||||
]
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("supervisor", "addon_not_installed", "not_onboarded")
|
||||
async def test_zeroconf_not_onboarded_not_installed(
|
||||
hass: HomeAssistant,
|
||||
supervisor: MagicMock,
|
||||
addon_info: AsyncMock,
|
||||
addon_store_info: AsyncMock,
|
||||
addon_not_installed: AsyncMock,
|
||||
install_addon: AsyncMock,
|
||||
start_addon: AsyncMock,
|
||||
client_connect: AsyncMock,
|
||||
setup_entry: AsyncMock,
|
||||
not_onboarded: MagicMock,
|
||||
zeroconf_info: ZeroconfServiceInfo,
|
||||
) -> None:
|
||||
"""Test flow Zeroconf discovery when not onboarded and add-on not installed."""
|
||||
@@ -467,7 +465,7 @@ async def test_zeroconf_not_onboarded_not_installed(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert addon_info.call_count == 0
|
||||
assert addon_store_info.call_count == 2
|
||||
assert addon_store_info.call_count == 1
|
||||
assert install_addon.call_args == call("core_matter_server")
|
||||
assert start_addon.call_args == call("core_matter_server")
|
||||
assert client_connect.call_count == 1
|
||||
|
||||
@@ -258,10 +258,7 @@ async def test_listen_failure_config_entry_loaded(
|
||||
|
||||
|
||||
async def test_raise_addon_task_in_progress(
|
||||
hass: HomeAssistant,
|
||||
addon_not_installed: AsyncMock,
|
||||
install_addon: AsyncMock,
|
||||
start_addon: AsyncMock,
|
||||
hass: HomeAssistant, install_addon: AsyncMock, start_addon: AsyncMock
|
||||
) -> None:
|
||||
"""Test raise ConfigEntryNotReady if an add-on task is in progress."""
|
||||
install_event = asyncio.Event()
|
||||
@@ -337,7 +334,6 @@ async def test_start_addon(
|
||||
|
||||
async def test_install_addon(
|
||||
hass: HomeAssistant,
|
||||
addon_not_installed: AsyncMock,
|
||||
addon_store_info: AsyncMock,
|
||||
install_addon: AsyncMock,
|
||||
start_addon: AsyncMock,
|
||||
@@ -357,7 +353,7 @@ async def test_install_addon(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.state is ConfigEntryState.SETUP_RETRY
|
||||
assert addon_store_info.call_count == 3
|
||||
assert addon_store_info.call_count == 2
|
||||
assert install_addon.call_count == 1
|
||||
assert install_addon.call_args == call("core_matter_server")
|
||||
assert start_addon.call_count == 1
|
||||
|
||||
@@ -82,6 +82,7 @@ light:
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import call, patch
|
||||
|
||||
@@ -100,6 +101,7 @@ from homeassistant.const import (
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.util.json import json_loads
|
||||
|
||||
from .common import (
|
||||
@@ -169,6 +171,70 @@ COLOR_MODES_CONFIG = {
|
||||
}
|
||||
}
|
||||
|
||||
GROUP_MEMBER_1_TOPIC = "homeassistant/light/member_1/config"
|
||||
GROUP_MEMBER_2_TOPIC = "homeassistant/light/member_2/config"
|
||||
GROUP_MEMBER_3_TOPIC = "homeassistant/light/member_3/config"
|
||||
GROUP_TOPIC = "homeassistant/light/group/config"
|
||||
GROUP_DISCOVERY_MEMBER_1_CONFIG = json.dumps(
|
||||
{
|
||||
"schema": "json",
|
||||
"command_topic": "test-command-topic-member1",
|
||||
"unique_id": "very_unique_member1",
|
||||
"name": "member1",
|
||||
"default_entity_id": "light.member1",
|
||||
}
|
||||
)
|
||||
GROUP_DISCOVERY_MEMBER_2_CONFIG = json.dumps(
|
||||
{
|
||||
"schema": "json",
|
||||
"command_topic": "test-command-topic-member2",
|
||||
"unique_id": "very_unique_member2",
|
||||
"name": "member2",
|
||||
"default_entity_id": "light.member2",
|
||||
}
|
||||
)
|
||||
GROUP_DISCOVERY_MEMBER_3_CONFIG = json.dumps(
|
||||
{
|
||||
"schema": "json",
|
||||
"command_topic": "test-command-topic-member3",
|
||||
"unique_id": "very_unique_member3",
|
||||
"name": "member3",
|
||||
"default_entity_id": "light.member3",
|
||||
}
|
||||
)
|
||||
GROUP_DISCOVERY_LIGHT_GROUP_CONFIG = json.dumps(
|
||||
{
|
||||
"schema": "json",
|
||||
"command_topic": "test-command-topic-group",
|
||||
"state_topic": "test-state-topic-group",
|
||||
"unique_id": "very_unique_group",
|
||||
"name": "group",
|
||||
"default_entity_id": "light.group",
|
||||
"group": ["very_unique_member1", "very_unique_member2"],
|
||||
}
|
||||
)
|
||||
GROUP_DISCOVERY_LIGHT_GROUP_CONFIG_EXPANDED = json.dumps(
|
||||
{
|
||||
"schema": "json",
|
||||
"command_topic": "test-command-topic-group",
|
||||
"state_topic": "test-state-topic-group",
|
||||
"unique_id": "very_unique_group",
|
||||
"name": "group",
|
||||
"default_entity_id": "light.group",
|
||||
"group": ["very_unique_member1", "very_unique_member2", "very_unique_member3"],
|
||||
}
|
||||
)
|
||||
GROUP_DISCOVERY_LIGHT_GROUP_CONFIG_NO_GROUP = json.dumps(
|
||||
{
|
||||
"schema": "json",
|
||||
"command_topic": "test-command-topic-group",
|
||||
"state_topic": "test-state-topic-group",
|
||||
"unique_id": "very_unique_group",
|
||||
"name": "group",
|
||||
"default_entity_id": "light.group",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class JsonValidator:
|
||||
"""Helper to compare JSON."""
|
||||
@@ -1859,6 +1925,144 @@ async def test_white_scale(
|
||||
assert state.attributes.get("brightness") == 129
|
||||
|
||||
|
||||
async def test_light_group_discovery_members_before_group(
|
||||
hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator
|
||||
) -> None:
|
||||
"""Test the discovery of a light group and linked entity IDs.
|
||||
|
||||
The members are discovered first, so they are known in the entity registry.
|
||||
"""
|
||||
await mqtt_mock_entry()
|
||||
# Discover light group members
|
||||
async_fire_mqtt_message(hass, GROUP_MEMBER_1_TOPIC, GROUP_DISCOVERY_MEMBER_1_CONFIG)
|
||||
async_fire_mqtt_message(hass, GROUP_MEMBER_2_TOPIC, GROUP_DISCOVERY_MEMBER_2_CONFIG)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Discover group
|
||||
async_fire_mqtt_message(hass, GROUP_TOPIC, GROUP_DISCOVERY_LIGHT_GROUP_CONFIG)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get("light.member1") is not None
|
||||
assert hass.states.get("light.member2") is not None
|
||||
group_state = hass.states.get("light.group")
|
||||
assert group_state is not None
|
||||
assert group_state.attributes.get("group_entities") == [
|
||||
"light.member1",
|
||||
"light.member2",
|
||||
]
|
||||
|
||||
# Now create and discover a new member
|
||||
async_fire_mqtt_message(hass, GROUP_MEMBER_3_TOPIC, GROUP_DISCOVERY_MEMBER_3_CONFIG)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Update the group discovery
|
||||
async_fire_mqtt_message(
|
||||
hass, GROUP_TOPIC, GROUP_DISCOVERY_LIGHT_GROUP_CONFIG_EXPANDED
|
||||
)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get("light.member1") is not None
|
||||
assert hass.states.get("light.member2") is not None
|
||||
assert hass.states.get("light.member3") is not None
|
||||
group_state = hass.states.get("light.group")
|
||||
assert group_state is not None
|
||||
assert group_state.attributes.get("group_entities") == [
|
||||
"light.member1",
|
||||
"light.member2",
|
||||
"light.member3",
|
||||
]
|
||||
|
||||
|
||||
async def test_light_group_discovery_group_before_members(
|
||||
hass: HomeAssistant,
|
||||
mqtt_mock_entry: MqttMockHAClientGenerator,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test the discovery of a light group and linked entity IDs.
|
||||
|
||||
The group is discovered first, so the group members are
|
||||
not (all) known yet in the entity registry.
|
||||
The entity property should be updated as soon as member entities
|
||||
are discovered, updated or removed.
|
||||
"""
|
||||
await mqtt_mock_entry()
|
||||
|
||||
# Discover group
|
||||
async_fire_mqtt_message(hass, GROUP_TOPIC, GROUP_DISCOVERY_LIGHT_GROUP_CONFIG)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Discover light group members
|
||||
async_fire_mqtt_message(hass, GROUP_MEMBER_1_TOPIC, GROUP_DISCOVERY_MEMBER_1_CONFIG)
|
||||
async_fire_mqtt_message(hass, GROUP_MEMBER_2_TOPIC, GROUP_DISCOVERY_MEMBER_2_CONFIG)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get("light.member1") is not None
|
||||
assert hass.states.get("light.member2") is not None
|
||||
|
||||
group_state = hass.states.get("light.group")
|
||||
assert group_state is not None
|
||||
assert group_state.attributes.get("group_entities") == [
|
||||
"light.member1",
|
||||
"light.member2",
|
||||
]
|
||||
|
||||
# Remove member 1
|
||||
async_fire_mqtt_message(hass, GROUP_MEMBER_1_TOPIC, "")
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get("light.member1") is None
|
||||
assert hass.states.get("light.member2") is not None
|
||||
|
||||
group_state = hass.states.get("light.group")
|
||||
assert group_state is not None
|
||||
assert group_state.attributes.get("group_entities") == ["light.member2"]
|
||||
|
||||
# Rename member 2
|
||||
entity_registry.async_update_entity(
|
||||
"light.member2", new_entity_id="light.member2_updated"
|
||||
)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
group_state = hass.states.get("light.group")
|
||||
assert group_state is not None
|
||||
assert group_state.attributes.get("group_entities") == ["light.member2_updated"]
|
||||
|
||||
|
||||
async def test_update_discovery_with_members_without_init(
|
||||
hass: HomeAssistant,
|
||||
mqtt_mock_entry: MqttMockHAClientGenerator,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test the discovery update of a light group and linked entity IDs."""
|
||||
await mqtt_mock_entry()
|
||||
# Discover light group members
|
||||
async_fire_mqtt_message(hass, GROUP_MEMBER_1_TOPIC, GROUP_DISCOVERY_MEMBER_1_CONFIG)
|
||||
async_fire_mqtt_message(hass, GROUP_MEMBER_2_TOPIC, GROUP_DISCOVERY_MEMBER_2_CONFIG)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Discover group without members
|
||||
async_fire_mqtt_message(
|
||||
hass, GROUP_TOPIC, GROUP_DISCOVERY_LIGHT_GROUP_CONFIG_NO_GROUP
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get("light.member1") is not None
|
||||
assert hass.states.get("light.member2") is not None
|
||||
group_state = hass.states.get("light.group")
|
||||
assert group_state is not None
|
||||
assert group_state.attributes.get("group_entities") is None
|
||||
|
||||
# Update the discovery with group members
|
||||
async_fire_mqtt_message(hass, GROUP_TOPIC, GROUP_DISCOVERY_LIGHT_GROUP_CONFIG)
|
||||
await hass.async_block_till_done()
|
||||
assert "Group member update received for entity" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hass_config",
|
||||
[
|
||||
@@ -2040,7 +2244,7 @@ async def test_custom_availability_payload(
|
||||
)
|
||||
|
||||
|
||||
async def test_setting_attribute_via_mqtt_json_message(
|
||||
async def test_setting_attribute_via_mqtt_json_message_single_light(
|
||||
hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator
|
||||
) -> None:
|
||||
"""Test the setting of attribute via MQTT with JSON payload."""
|
||||
@@ -2049,6 +2253,54 @@ async def test_setting_attribute_via_mqtt_json_message(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hass_config",
|
||||
[
|
||||
help_custom_config(
|
||||
light.DOMAIN,
|
||||
DEFAULT_CONFIG,
|
||||
(
|
||||
{
|
||||
"unique_id": "very_unique_member_1",
|
||||
"name": "Part 1",
|
||||
"default_entity_id": "light.member_1",
|
||||
},
|
||||
{
|
||||
"unique_id": "very_unique_member_2",
|
||||
"name": "Part 2",
|
||||
"default_entity_id": "light.member_2",
|
||||
},
|
||||
{
|
||||
"unique_id": "very_unique_group",
|
||||
"name": "My group",
|
||||
"default_entity_id": "light.my_group",
|
||||
"json_attributes_topic": "attr-topic",
|
||||
"group": [
|
||||
"very_unique_member_1",
|
||||
"very_unique_member_2",
|
||||
"member_3_not_exists",
|
||||
],
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
async def test_setting_attribute_via_mqtt_json_message_light_group(
|
||||
hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator
|
||||
) -> None:
|
||||
"""Test the setting of attribute via MQTT with JSON payload."""
|
||||
await mqtt_mock_entry()
|
||||
|
||||
async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }')
|
||||
state = hass.states.get("light.my_group")
|
||||
|
||||
assert state and state.attributes.get("val") == "100"
|
||||
assert state.attributes.get("group_entities") == [
|
||||
"light.member_1",
|
||||
"light.member_2",
|
||||
]
|
||||
|
||||
|
||||
async def test_setting_blocked_attribute_via_mqtt_json_message(
|
||||
hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator
|
||||
) -> None:
|
||||
|
||||
@@ -3082,63 +3082,6 @@
|
||||
'state': '46.92',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.test_energy_remaining_2-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.test_energy_remaining_2',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Energy remaining',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 1,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.ENERGY_STORAGE: 'energy_storage'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Energy remaining',
|
||||
'platform': 'tessie',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'energy_remaining',
|
||||
'unique_id': 'VINVINVIN-energy_remaining',
|
||||
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.test_energy_remaining_2-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'energy_storage',
|
||||
'friendly_name': 'Test Energy remaining',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.test_energy_remaining_2',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '55.2',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.test_inside_temperature-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
|
||||
@@ -651,7 +651,7 @@ async def test_abort_hassio_discovery_for_other_addon(hass: HomeAssistant) -> No
|
||||
assert result2["reason"] == "not_zwave_js_addon"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("supervisor", "addon_not_installed", "addon_info")
|
||||
@pytest.mark.usefixtures("supervisor", "addon_info")
|
||||
@pytest.mark.parametrize(
|
||||
("usb_discovery_info", "device", "discovery_name"),
|
||||
[
|
||||
@@ -1176,7 +1176,7 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout(
|
||||
@pytest.mark.parametrize(
|
||||
"service_info", [ESPHOME_DISCOVERY_INFO, ESPHOME_DISCOVERY_INFO_CLEAN]
|
||||
)
|
||||
@pytest.mark.usefixtures("supervisor", "addon_not_installed", "addon_info")
|
||||
@pytest.mark.usefixtures("supervisor", "addon_info")
|
||||
async def test_esphome_discovery_intent_custom(
|
||||
hass: HomeAssistant,
|
||||
install_addon: AsyncMock,
|
||||
@@ -1460,7 +1460,7 @@ async def test_esphome_discovery_already_configured_unmanaged_addon(
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("supervisor", "addon_not_installed", "addon_info")
|
||||
@pytest.mark.usefixtures("supervisor", "addon_info")
|
||||
async def test_esphome_discovery_usb_same_home_id(
|
||||
hass: HomeAssistant,
|
||||
install_addon: AsyncMock,
|
||||
@@ -1699,7 +1699,7 @@ async def test_discovery_addon_not_running(
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("supervisor", "addon_not_installed", "addon_info")
|
||||
@pytest.mark.usefixtures("supervisor", "addon_info")
|
||||
async def test_discovery_addon_not_installed(
|
||||
hass: HomeAssistant,
|
||||
install_addon: AsyncMock,
|
||||
@@ -2768,7 +2768,7 @@ async def test_addon_installed_already_configured(
|
||||
assert entry.data["lr_s2_authenticated_key"] == "new321"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("supervisor", "addon_not_installed", "addon_info")
|
||||
@pytest.mark.usefixtures("supervisor", "addon_info")
|
||||
async def test_addon_not_installed(
|
||||
hass: HomeAssistant,
|
||||
install_addon: AsyncMock,
|
||||
@@ -3873,7 +3873,7 @@ async def test_reconfigure_addon_running_server_info_failure(
|
||||
assert client.disconnect.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("supervisor", "addon_not_installed")
|
||||
@pytest.mark.usefixtures("supervisor")
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"entry_data",
|
||||
@@ -5036,7 +5036,7 @@ async def test_get_usb_ports_ignored_devices() -> None:
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("supervisor", "addon_not_installed", "addon_info")
|
||||
@pytest.mark.usefixtures("supervisor", "addon_info")
|
||||
async def test_intent_recommended_user(
|
||||
hass: HomeAssistant,
|
||||
install_addon: AsyncMock,
|
||||
@@ -5132,7 +5132,7 @@ async def test_intent_recommended_user(
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("supervisor", "addon_not_installed", "addon_info")
|
||||
@pytest.mark.usefixtures("supervisor", "addon_info")
|
||||
@pytest.mark.parametrize(
|
||||
("usb_discovery_info", "device", "discovery_name"),
|
||||
[
|
||||
|
||||
@@ -933,7 +933,7 @@ async def test_start_addon(
|
||||
assert start_addon.call_args == call("core_zwave_js")
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("addon_not_installed", "addon_info")
|
||||
@pytest.mark.usefixtures("addon_info")
|
||||
async def test_install_addon(
|
||||
hass: HomeAssistant,
|
||||
install_addon: AsyncMock,
|
||||
|
||||
Reference in New Issue
Block a user