Compare commits

..

5 Commits

Author SHA1 Message Date
Mike Degatano
87c56c3a53 Remove unused check availability method and fix tests 2026-03-12 02:37:27 +00:00
Mike Degatano
6498298d56 Improve error handling when addon unavailable for install/update 2026-03-12 02:37:27 +00:00
Jan Bouwhuis
c1acd1d860 Allow an MQTT entity to show as a group (#152270)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2026-03-11 22:25:28 +01:00
chli1
f4748aa63d fix #163316: FRITZ!SmartHome integration not showing boost status on … (#164574) 2026-03-11 22:19:43 +01:00
Brett Adams
31f4f618cc Fix duplicate energy remaining sensors in Tessie (#165102) 2026-03-11 21:39:35 +01:00
21 changed files with 393 additions and 150 deletions

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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(

View File

@@ -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",

View File

@@ -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)

View File

@@ -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"

View File

@@ -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

View File

@@ -217,9 +217,6 @@
"energy_left": {
"default": "mdi:battery"
},
"energy_remaining": {
"default": "mdi:battery-medium"
},
"generator_power": {
"default": "mdi:generator-stationary"
},

View File

@@ -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,

View File

@@ -458,9 +458,6 @@
"energy_left": {
"name": "Energy left"
},
"energy_remaining": {
"name": "Energy remaining"
},
"generator_energy_exported": {
"name": "Generator exported"
},

View File

@@ -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)

View File

@@ -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

View File

@@ -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."""

View File

@@ -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"})

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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({

View File

@@ -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"),
[

View File

@@ -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,