Skip to content

Commit c6e334c

Browse files
Skip adding Control4 rooms with no audio/video sources as media player devices (home-assistant#154348)
Co-authored-by: Joostlek <[email protected]>
1 parent 416f6b9 commit c6e334c

File tree

8 files changed

+266
-15
lines changed

8 files changed

+266
-15
lines changed

homeassistant/components/control4/media_player.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,15 @@ async def async_update_data() -> dict[int, dict[str, Any]]:
148148
source_type={dev_type}, idx=dev_id, name=name
149149
)
150150

151+
# Skip rooms with no audio/video sources
152+
if not sources:
153+
_LOGGER.debug(
154+
"Skipping room '%s' (ID: %s) - no audio/video sources found",
155+
room.get("name"),
156+
room_id,
157+
)
158+
continue
159+
151160
try:
152161
hidden = room["roomHidden"]
153162
entity_list.append(
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,18 @@
11
"""Tests for the Control4 integration."""
2+
3+
from homeassistant.core import HomeAssistant
4+
5+
from tests.common import MockConfigEntry
6+
7+
8+
async def setup_integration(
9+
hass: HomeAssistant,
10+
mock_config_entry: MockConfigEntry,
11+
) -> MockConfigEntry:
12+
"""Set up the Control4 integration for testing."""
13+
mock_config_entry.add_to_hass(hass)
14+
15+
await hass.config_entries.async_setup(mock_config_entry.entry_id)
16+
await hass.async_block_till_done()
17+
18+
return mock_config_entry
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""Common fixtures for the Control4 tests."""
2+
3+
from collections.abc import AsyncGenerator, Generator
4+
from unittest.mock import AsyncMock, MagicMock, patch
5+
6+
import pytest
7+
8+
from homeassistant.components.control4.const import DOMAIN
9+
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
10+
11+
from tests.common import MockConfigEntry, load_fixture
12+
13+
MOCK_HOST = "192.168.1.100"
14+
MOCK_USERNAME = "test-username"
15+
MOCK_PASSWORD = "test-password"
16+
MOCK_CONTROLLER_UNIQUE_ID = "control4_test_123"
17+
18+
19+
@pytest.fixture
20+
def mock_config_entry() -> MockConfigEntry:
21+
"""Return the default mocked config entry."""
22+
return MockConfigEntry(
23+
domain=DOMAIN,
24+
data={
25+
CONF_HOST: MOCK_HOST,
26+
CONF_USERNAME: MOCK_USERNAME,
27+
CONF_PASSWORD: MOCK_PASSWORD,
28+
"controller_unique_id": MOCK_CONTROLLER_UNIQUE_ID,
29+
},
30+
unique_id="00:aa:00:aa:00:aa",
31+
)
32+
33+
34+
@pytest.fixture
35+
def mock_c4_account() -> Generator[MagicMock]:
36+
"""Mock a Control4 Account client."""
37+
with patch(
38+
"homeassistant.components.control4.C4Account", autospec=True
39+
) as mock_account_class:
40+
mock_account = mock_account_class.return_value
41+
mock_account.getAccountBearerToken = AsyncMock()
42+
mock_account.getAccountControllers = AsyncMock(
43+
return_value={"href": "https://example.com"}
44+
)
45+
mock_account.getDirectorBearerToken = AsyncMock(return_value={"token": "test"})
46+
mock_account.getControllerOSVersion = AsyncMock(return_value="3.2.0")
47+
yield mock_account
48+
49+
50+
@pytest.fixture
51+
def mock_c4_director() -> Generator[MagicMock]:
52+
"""Mock a Control4 Director client."""
53+
with patch(
54+
"homeassistant.components.control4.C4Director", autospec=True
55+
) as mock_director_class:
56+
mock_director = mock_director_class.return_value
57+
# Default: Multi-room setup (room with sources, room without sources)
58+
# Note: The API returns JSON strings, so we load fixtures as strings
59+
mock_director.getAllItemInfo = AsyncMock(
60+
return_value=load_fixture("director_all_items.json", DOMAIN)
61+
)
62+
mock_director.getUiConfiguration = AsyncMock(
63+
return_value=load_fixture("ui_configuration.json", DOMAIN)
64+
)
65+
yield mock_director
66+
67+
68+
@pytest.fixture
69+
def mock_update_variables() -> Generator[AsyncMock]:
70+
"""Mock the update_variables_for_config_entry function."""
71+
72+
async def _mock_update_variables(*args, **kwargs):
73+
return {
74+
1: {
75+
"POWER_STATE": True,
76+
"CURRENT_VOLUME": 50,
77+
"IS_MUTED": False,
78+
"CURRENT_VIDEO_DEVICE": 100,
79+
"CURRENT MEDIA INFO": {},
80+
"PLAYING": False,
81+
"PAUSED": False,
82+
"STOPPED": False,
83+
}
84+
}
85+
86+
with patch(
87+
"homeassistant.components.control4.media_player.update_variables_for_config_entry",
88+
new=_mock_update_variables,
89+
) as mock_update:
90+
yield mock_update
91+
92+
93+
@pytest.fixture
94+
def platforms() -> list[str]:
95+
"""Platforms which should be loaded during the test."""
96+
return ["media_player"]
97+
98+
99+
@pytest.fixture(autouse=True)
100+
async def mock_patch_platforms(platforms: list[str]) -> AsyncGenerator[None]:
101+
"""Fixture to set up platforms for tests."""
102+
with patch("homeassistant.components.control4.PLATFORMS", platforms):
103+
yield
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[
2+
{
3+
"id": 1,
4+
"typeName": "room",
5+
"name": "Living Room",
6+
"roomHidden": false
7+
},
8+
{
9+
"id": 2,
10+
"typeName": "room",
11+
"name": "Thermostat Room",
12+
"roomHidden": false
13+
},
14+
{
15+
"id": 100,
16+
"name": "TV"
17+
}
18+
]
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"experiences": [
3+
{
4+
"room_id": 1,
5+
"type": "watch",
6+
"sources": {
7+
"source": [
8+
{
9+
"id": 100
10+
}
11+
]
12+
}
13+
}
14+
]
15+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# serializer version: 1
2+
# name: test_media_player_with_and_without_sources[media_player.living_room-entry]
3+
EntityRegistryEntrySnapshot({
4+
'aliases': set({
5+
}),
6+
'area_id': None,
7+
'capabilities': dict({
8+
'source_list': list([
9+
'TV',
10+
]),
11+
}),
12+
'config_entry_id': <ANY>,
13+
'config_subentry_id': <ANY>,
14+
'device_class': None,
15+
'device_id': <ANY>,
16+
'disabled_by': None,
17+
'domain': 'media_player',
18+
'entity_category': None,
19+
'entity_id': 'media_player.living_room',
20+
'has_entity_name': True,
21+
'hidden_by': None,
22+
'icon': None,
23+
'id': <ANY>,
24+
'labels': set({
25+
}),
26+
'name': None,
27+
'options': dict({
28+
}),
29+
'original_device_class': <MediaPlayerDeviceClass.TV: 'tv'>,
30+
'original_icon': None,
31+
'original_name': None,
32+
'platform': 'control4',
33+
'previous_unique_id': None,
34+
'suggested_object_id': None,
35+
'supported_features': <MediaPlayerEntityFeature: 23821>,
36+
'translation_key': None,
37+
'unique_id': '1',
38+
'unit_of_measurement': None,
39+
})
40+
# ---
41+
# name: test_media_player_with_and_without_sources[media_player.living_room-state]
42+
StateSnapshot({
43+
'attributes': ReadOnlyDict({
44+
'device_class': 'tv',
45+
'friendly_name': 'Living Room',
46+
'is_volume_muted': False,
47+
'source_list': list([
48+
'TV',
49+
]),
50+
'supported_features': <MediaPlayerEntityFeature: 23821>,
51+
'volume_level': 0.5,
52+
}),
53+
'context': <ANY>,
54+
'entity_id': 'media_player.living_room',
55+
'last_changed': <ANY>,
56+
'last_reported': <ANY>,
57+
'last_updated': <ANY>,
58+
'state': 'on',
59+
})
60+
# ---

tests/components/control4/test_config_flow.py

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
from homeassistant.core import HomeAssistant
1818
from homeassistant.data_entry_flow import FlowResultType
1919

20+
from .conftest import MOCK_HOST, MOCK_PASSWORD, MOCK_USERNAME
21+
2022
from tests.common import MockConfigEntry
2123

2224

@@ -69,21 +71,22 @@ async def test_form(hass: HomeAssistant) -> None:
6971
result2 = await hass.config_entries.flow.async_configure(
7072
result["flow_id"],
7173
{
72-
CONF_HOST: "1.1.1.1",
73-
CONF_USERNAME: "test-username",
74-
CONF_PASSWORD: "test-password",
74+
CONF_HOST: MOCK_HOST,
75+
CONF_USERNAME: MOCK_USERNAME,
76+
CONF_PASSWORD: MOCK_PASSWORD,
7577
},
7678
)
7779
await hass.async_block_till_done()
7880

7981
assert result2["type"] is FlowResultType.CREATE_ENTRY
8082
assert result2["title"] == "control4_model_00AA00AA00AA"
8183
assert result2["data"] == {
82-
CONF_HOST: "1.1.1.1",
83-
CONF_USERNAME: "test-username",
84-
CONF_PASSWORD: "test-password",
84+
CONF_HOST: MOCK_HOST,
85+
CONF_USERNAME: MOCK_USERNAME,
86+
CONF_PASSWORD: MOCK_PASSWORD,
8587
"controller_unique_id": "control4_model_00AA00AA00AA",
8688
}
89+
assert result2["result"].unique_id == "00:aa:00:aa:00:aa"
8790
assert len(mock_setup_entry.mock_calls) == 1
8891

8992

@@ -100,9 +103,9 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None:
100103
result2 = await hass.config_entries.flow.async_configure(
101104
result["flow_id"],
102105
{
103-
CONF_HOST: "1.1.1.1",
104-
CONF_USERNAME: "test-username",
105-
CONF_PASSWORD: "test-password",
106+
CONF_HOST: MOCK_HOST,
107+
CONF_USERNAME: MOCK_USERNAME,
108+
CONF_PASSWORD: MOCK_PASSWORD,
106109
},
107110
)
108111

@@ -123,9 +126,9 @@ async def test_form_unexpected_exception(hass: HomeAssistant) -> None:
123126
result2 = await hass.config_entries.flow.async_configure(
124127
result["flow_id"],
125128
{
126-
CONF_HOST: "1.1.1.1",
127-
CONF_USERNAME: "test-username",
128-
CONF_PASSWORD: "test-password",
129+
CONF_HOST: MOCK_HOST,
130+
CONF_USERNAME: MOCK_USERNAME,
131+
CONF_PASSWORD: MOCK_PASSWORD,
129132
},
130133
)
131134

@@ -152,9 +155,9 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None:
152155
result2 = await hass.config_entries.flow.async_configure(
153156
result["flow_id"],
154157
{
155-
CONF_HOST: "1.1.1.1",
156-
CONF_USERNAME: "test-username",
157-
CONF_PASSWORD: "test-password",
158+
CONF_HOST: MOCK_HOST,
159+
CONF_USERNAME: MOCK_USERNAME,
160+
CONF_PASSWORD: MOCK_PASSWORD,
158161
},
159162
)
160163

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""Test Control4 Media Player."""
2+
3+
import pytest
4+
from syrupy.assertion import SnapshotAssertion
5+
6+
from homeassistant.core import HomeAssistant
7+
from homeassistant.helpers import entity_registry as er
8+
9+
from . import setup_integration
10+
11+
from tests.common import MockConfigEntry, snapshot_platform
12+
13+
14+
@pytest.mark.usefixtures("mock_c4_account", "mock_c4_director", "mock_update_variables")
15+
async def test_media_player_with_and_without_sources(
16+
hass: HomeAssistant,
17+
mock_config_entry: MockConfigEntry,
18+
entity_registry: er.EntityRegistry,
19+
snapshot: SnapshotAssertion,
20+
) -> None:
21+
"""Test that rooms with sources create entities and rooms without are skipped."""
22+
# The default mock_c4_director fixture provides multi-room data:
23+
# Room 1 has video source, Room 2 has no sources (thermostat-only room)
24+
await setup_integration(hass, mock_config_entry)
25+
26+
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)

0 commit comments

Comments
 (0)