From b9991954a4784233faa71811ca2ccb8298ffed93 Mon Sep 17 00:00:00 2001 From: "Dr.Blank" Date: Sat, 29 Aug 2026 19:43:21 +0530 Subject: [PATCH] Add Persang Infrared integration (#178107) --- CODEOWNERS | 2 + .../components/persang_infrared/__init__.py | 18 ++ .../persang_infrared/config_flow.py | 51 +++++ .../components/persang_infrared/const.py | 4 + .../components/persang_infrared/entity.py | 23 +++ .../components/persang_infrared/manifest.json | 11 ++ .../persang_infrared/media_player.py | 121 ++++++++++++ .../persang_infrared/quality_scale.yaml | 127 ++++++++++++ .../components/persang_infrared/strings.json | 20 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + tests/components/persang_infrared/__init__.py | 1 + tests/components/persang_infrared/conftest.py | 72 +++++++ .../snapshots/test_media_player.ambr | 55 ++++++ .../persang_infrared/test_config_flow.py | 71 +++++++ .../components/persang_infrared/test_init.py | 19 ++ .../persang_infrared/test_media_player.py | 187 ++++++++++++++++++ 17 files changed, 789 insertions(+) create mode 100644 homeassistant/components/persang_infrared/__init__.py create mode 100644 homeassistant/components/persang_infrared/config_flow.py create mode 100644 homeassistant/components/persang_infrared/const.py create mode 100644 homeassistant/components/persang_infrared/entity.py create mode 100644 homeassistant/components/persang_infrared/manifest.json create mode 100644 homeassistant/components/persang_infrared/media_player.py create mode 100644 homeassistant/components/persang_infrared/quality_scale.yaml create mode 100644 homeassistant/components/persang_infrared/strings.json create mode 100644 tests/components/persang_infrared/__init__.py create mode 100644 tests/components/persang_infrared/conftest.py create mode 100644 tests/components/persang_infrared/snapshots/test_media_player.ambr create mode 100644 tests/components/persang_infrared/test_config_flow.py create mode 100644 tests/components/persang_infrared/test_init.py create mode 100644 tests/components/persang_infrared/test_media_player.py diff --git a/CODEOWNERS b/CODEOWNERS index 0c74cbd05d23..e62ce07ea5b6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1410,6 +1410,8 @@ CLAUDE.md @home-assistant/core /tests/components/peco/ @IceBotYT /homeassistant/components/pegel_online/ @mib1185 /tests/components/pegel_online/ @mib1185 +/homeassistant/components/persang_infrared/ @Dr-Blank +/tests/components/persang_infrared/ @Dr-Blank /homeassistant/components/persistent_notification/ @home-assistant/core /tests/components/persistent_notification/ @home-assistant/core /homeassistant/components/pglab/ @pglab-electronics diff --git a/homeassistant/components/persang_infrared/__init__.py b/homeassistant/components/persang_infrared/__init__.py new file mode 100644 index 000000000000..5f9f3eeb3b20 --- /dev/null +++ b/homeassistant/components/persang_infrared/__init__.py @@ -0,0 +1,18 @@ +"""Persang Infrared integration for Home Assistant.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS = [Platform.MEDIA_PLAYER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Persang IR from a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a Persang IR config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/persang_infrared/config_flow.py b/homeassistant/components/persang_infrared/config_flow.py new file mode 100644 index 000000000000..e30a98fa00d0 --- /dev/null +++ b/homeassistant/components/persang_infrared/config_flow.py @@ -0,0 +1,51 @@ +"""Config flow for the Persang Infrared integration.""" + +from typing import Any, override + +import voluptuous as vol + +from homeassistant.components.infrared import ( + DOMAIN as INFRARED_DOMAIN, + async_get_emitters, +) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers.selector import EntitySelector, EntitySelectorConfig + +from .const import CONF_INFRARED_EMITTER_ENTITY_ID, DOMAIN + + +class PersangIrConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle config flow for Persang IR.""" + + VERSION = 1 + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + emitter_entity_ids = async_get_emitters(self.hass) + if not emitter_entity_ids: + return self.async_abort(reason="no_emitters") + + if user_input is not None: + entity_id = user_input[CONF_INFRARED_EMITTER_ENTITY_ID] + + await self.async_set_unique_id(entity_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry(title="Persang speaker", data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_INFRARED_EMITTER_ENTITY_ID): EntitySelector( + EntitySelectorConfig( + domain=INFRARED_DOMAIN, + include_entities=emitter_entity_ids, + ) + ), + } + ), + ) diff --git a/homeassistant/components/persang_infrared/const.py b/homeassistant/components/persang_infrared/const.py new file mode 100644 index 000000000000..59bd246f316a --- /dev/null +++ b/homeassistant/components/persang_infrared/const.py @@ -0,0 +1,4 @@ +"""Constants for the Persang Infrared integration.""" + +DOMAIN = "persang_infrared" +CONF_INFRARED_EMITTER_ENTITY_ID = "infrared_emitter_entity_id" diff --git a/homeassistant/components/persang_infrared/entity.py b/homeassistant/components/persang_infrared/entity.py new file mode 100644 index 000000000000..f90f3f88d46e --- /dev/null +++ b/homeassistant/components/persang_infrared/entity.py @@ -0,0 +1,23 @@ +"""Common entity for the Persang Infrared integration.""" + +from homeassistant.components.infrared import InfraredEmitterConsumerEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.device_registry import DeviceInfo + +from .const import DOMAIN + + +class PersangIrEntity(InfraredEmitterConsumerEntity): + """Persang IR base entity.""" + + _attr_has_entity_name = True + + def __init__(self, entry: ConfigEntry, infrared_entity_id: str) -> None: + """Initialize Persang IR entity.""" + self._infrared_emitter_entity_id = infrared_entity_id + self._attr_unique_id = entry.entry_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name="Persang speaker", + manufacturer="Persang", + ) diff --git a/homeassistant/components/persang_infrared/manifest.json b/homeassistant/components/persang_infrared/manifest.json new file mode 100644 index 000000000000..ee2b9e56b879 --- /dev/null +++ b/homeassistant/components/persang_infrared/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "persang_infrared", + "name": "Persang Infrared", + "codeowners": ["@Dr-Blank"], + "config_flow": true, + "dependencies": ["infrared"], + "documentation": "https://www.home-assistant.io/integrations/persang_infrared", + "integration_type": "device", + "iot_class": "assumed_state", + "quality_scale": "bronze" +} diff --git a/homeassistant/components/persang_infrared/media_player.py b/homeassistant/components/persang_infrared/media_player.py new file mode 100644 index 000000000000..07ab91000f45 --- /dev/null +++ b/homeassistant/components/persang_infrared/media_player.py @@ -0,0 +1,121 @@ +"""Media player platform for the Persang Infrared integration.""" + +from typing import override + +from infrared_protocols.codes.persang.speaker import PersangSpeakerCode + +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerEntityFeature, + MediaPlayerState, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity + +from .const import CONF_INFRARED_EMITTER_ENTITY_ID +from .entity import PersangIrEntity + +PARALLEL_UPDATES = 1 + +RESTORED_STATES = ( + MediaPlayerState.ON, + MediaPlayerState.OFF, + MediaPlayerState.PLAYING, + MediaPlayerState.PAUSED, +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Persang IR media player from a config entry.""" + infrared_entity_id = entry.data[CONF_INFRARED_EMITTER_ENTITY_ID] + async_add_entities([PersangIrMediaPlayer(entry, infrared_entity_id)]) + + +class PersangIrMediaPlayer(PersangIrEntity, MediaPlayerEntity, RestoreEntity): + """Persang IR speaker media player entity.""" + + _attr_name = None + _attr_assumed_state = True + _attr_device_class = MediaPlayerDeviceClass.SPEAKER + _attr_supported_features = ( + MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.PREVIOUS_TRACK + ) + + @override + async def async_added_to_hass(self) -> None: + """Restore the last assumed state.""" + await super().async_added_to_hass() + + if (last_state := await self.async_get_last_state()) is not None and ( + last_state.state in RESTORED_STATES + ): + self._attr_state = MediaPlayerState(last_state.state) + + @override + async def async_turn_on(self) -> None: + """Send the power command.""" + await self._send_command(PersangSpeakerCode.POWER.to_command()) + self._attr_state = MediaPlayerState.ON + self.async_write_ha_state() + + @override + async def async_turn_off(self) -> None: + """Send the power command.""" + await self._send_command(PersangSpeakerCode.POWER.to_command()) + self._attr_state = MediaPlayerState.OFF + self.async_write_ha_state() + + @override + async def async_volume_up(self) -> None: + """Send the volume up command.""" + await self._send_command(PersangSpeakerCode.VOLUME_UP.to_command()) + + @override + async def async_volume_down(self) -> None: + """Send the volume down command.""" + await self._send_command(PersangSpeakerCode.VOLUME_DOWN.to_command()) + + @override + async def async_mute_volume(self, mute: bool) -> None: + """Send the mute command.""" + await self._send_command(PersangSpeakerCode.MUTE.to_command()) + self._attr_is_volume_muted = mute + self.async_write_ha_state() + + @override + async def async_media_play(self) -> None: + """Send the play/pause command.""" + await self._send_command(PersangSpeakerCode.PLAY_PAUSE.to_command()) + self._attr_state = MediaPlayerState.PLAYING + self.async_write_ha_state() + + @override + async def async_media_pause(self) -> None: + """Send the play/pause command.""" + await self._send_command(PersangSpeakerCode.PLAY_PAUSE.to_command()) + self._attr_state = MediaPlayerState.PAUSED + self.async_write_ha_state() + + @override + async def async_media_next_track(self) -> None: + """Send the next track command.""" + await self._send_command(PersangSpeakerCode.NEXT.to_command()) + + @override + async def async_media_previous_track(self) -> None: + """Send the previous track command.""" + await self._send_command(PersangSpeakerCode.PREVIOUS.to_command()) diff --git a/homeassistant/components/persang_infrared/quality_scale.yaml b/homeassistant/components/persang_infrared/quality_scale.yaml new file mode 100644 index 000000000000..f4e31311defa --- /dev/null +++ b/homeassistant/components/persang_infrared/quality_scale.yaml @@ -0,0 +1,127 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: | + This integration does not poll. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-conditions: + status: exempt + comment: This integration does not have any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not have any triggers. + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: + status: exempt + comment: | + This integration does not store runtime data. + test-before-configure: + status: exempt + comment: | + This integration only proxies commands through an existing infrared + entity, so there is no connection to test in the config flow. + test-before-setup: + status: exempt + comment: | + This integration only proxies commands through an existing infrared + entity, so there is no separate connection to validate during setup. + unique-config-entry: done + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not register custom actions. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: done + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + This integration does not support discovery. + discovery: + status: exempt + comment: | + This integration is configured manually via config flow. + docs-data-update: + status: exempt + comment: | + This integration does not fetch data from devices. + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: + status: exempt + comment: | + Each config entry creates a single device. + entity-category: + status: exempt + comment: | + The media player entity is the primary entity and does not need a category. + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: | + No entities should be disabled by default. + entity-translations: done + exception-translations: + status: exempt + comment: | + This integration does not raise exceptions. + icon-translations: + status: exempt + comment: | + This integration does not use custom icons. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + This integration does not have repairable issues. + stale-devices: + status: exempt + comment: | + Each config entry manages exactly one device. + + # Platinum + async-dependency: + status: exempt + comment: | + This integration depends on infrared_protocols, which provides only code + definitions with no I/O, so async dependency does not apply. + inject-websession: + status: exempt + comment: | + This integration does not make HTTP requests. + strict-typing: todo diff --git a/homeassistant/components/persang_infrared/strings.json b/homeassistant/components/persang_infrared/strings.json new file mode 100644 index 000000000000..a74f5916bfb4 --- /dev/null +++ b/homeassistant/components/persang_infrared/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "This Persang speaker has already been configured with this transmitter.", + "no_emitters": "No infrared transmitter entities found. Please set up an infrared device first." + }, + "step": { + "user": { + "data": { + "infrared_emitter_entity_id": "Infrared transmitter" + }, + "data_description": { + "infrared_emitter_entity_id": "The infrared transmitter entity to use for sending commands." + }, + "description": "Select the infrared transmitter entity to use for controlling your Persang speaker.", + "title": "Set up Persang IR speaker" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 6630e10402be..2b010508cc07 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -599,6 +599,7 @@ FLOWS = { "peblar", "peco", "pegel_online", + "persang_infrared", "pglab", "philips_js", "pi_hole", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1d08f6364040..79a286946fcf 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5497,6 +5497,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "persang_infrared": { + "name": "Persang Infrared", + "integration_type": "device", + "config_flow": true, + "iot_class": "assumed_state" + }, "pge": { "name": "Pacific Gas & Electric (PG&E)", "integration_type": "virtual", diff --git a/tests/components/persang_infrared/__init__.py b/tests/components/persang_infrared/__init__.py new file mode 100644 index 000000000000..f8a98e13edb4 --- /dev/null +++ b/tests/components/persang_infrared/__init__.py @@ -0,0 +1 @@ +"""Tests for the Persang Infrared integration.""" diff --git a/tests/components/persang_infrared/conftest.py b/tests/components/persang_infrared/conftest.py new file mode 100644 index 000000000000..e752198be3ed --- /dev/null +++ b/tests/components/persang_infrared/conftest.py @@ -0,0 +1,72 @@ +"""Common fixtures for the Persang Infrared tests.""" + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +from infrared_protocols.codes.persang.speaker import PersangSpeakerCode +import pytest + +from homeassistant.components.persang_infrared import PLATFORMS +from homeassistant.components.persang_infrared.const import ( + CONF_INFRARED_EMITTER_ENTITY_ID, + DOMAIN, +) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.infrared import ( + EMITTER_ENTITY_ID as MOCK_INFRARED_EMITTER_ENTITY_ID, +) +from tests.components.infrared.common import MockInfraredEmitterEntity + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + entry_id="01JTEST0000000000000000000", + title="Persang speaker", + data={CONF_INFRARED_EMITTER_ENTITY_ID: MOCK_INFRARED_EMITTER_ENTITY_ID}, + unique_id=MOCK_INFRARED_EMITTER_ENTITY_ID, + ) + + +@pytest.fixture +def platforms() -> list[Platform]: + """Return platforms to set up.""" + return PLATFORMS + + +@pytest.fixture +def mock_persang_to_command() -> Generator[MagicMock]: + """Patch ``PersangSpeakerCode.to_command`` to return the code itself. + + This lets tests assert on the code enum rather than on raw NEC timings. + """ + with patch.object( + PersangSpeakerCode, + "to_command", + autospec=True, + side_effect=lambda self, **kwargs: self, + ) as mock: + yield mock + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + mock_persang_to_command: MagicMock, + platforms: list[Platform], +) -> MockConfigEntry: + """Set up the Persang Infrared integration for testing.""" + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.persang_infrared.PLATFORMS", platforms): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/persang_infrared/snapshots/test_media_player.ambr b/tests/components/persang_infrared/snapshots/test_media_player.ambr new file mode 100644 index 000000000000..efde2b6bcfb1 --- /dev/null +++ b/tests/components/persang_infrared/snapshots/test_media_player.ambr @@ -0,0 +1,55 @@ +# serializer version: 1 +# name: test_entities[media_player.persang_speaker-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.persang_speaker', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'persang_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '01JTEST0000000000000000000', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[media_player.persang_speaker-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : True, + : 'speaker', + : 'Persang speaker', + : , + }), + 'context': , + 'entity_id': 'media_player.persang_speaker', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/persang_infrared/test_config_flow.py b/tests/components/persang_infrared/test_config_flow.py new file mode 100644 index 000000000000..38612298d9a0 --- /dev/null +++ b/tests/components/persang_infrared/test_config_flow.py @@ -0,0 +1,71 @@ +"""Tests for the Persang Infrared config flow.""" + +import pytest + +from homeassistant.components.persang_infrared.const import ( + CONF_INFRARED_EMITTER_ENTITY_ID, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import MOCK_INFRARED_EMITTER_ENTITY_ID + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_user_flow_success(hass: HomeAssistant) -> None: + """Test successful user config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_INFRARED_EMITTER_ENTITY_ID: MOCK_INFRARED_EMITTER_ENTITY_ID}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Persang speaker" + assert result["data"] == { + CONF_INFRARED_EMITTER_ENTITY_ID: MOCK_INFRARED_EMITTER_ENTITY_ID + } + assert result["result"].unique_id == MOCK_INFRARED_EMITTER_ENTITY_ID + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_user_flow_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test user flow aborts when the transmitter is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_INFRARED_EMITTER_ENTITY_ID: MOCK_INFRARED_EMITTER_ENTITY_ID}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("init_infrared") +async def test_user_flow_no_emitters(hass: HomeAssistant) -> None: + """Test user flow aborts when no infrared emitters exist.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_emitters" diff --git a/tests/components/persang_infrared/test_init.py b/tests/components/persang_infrared/test_init.py new file mode 100644 index 000000000000..aec19fa8a36c --- /dev/null +++ b/tests/components/persang_infrared/test_init.py @@ -0,0 +1,19 @@ +"""Tests for the Persang Infrared integration setup.""" + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup_and_unload_entry( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Test setting up and unloading a config entry.""" + entry = init_integration + assert entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/persang_infrared/test_media_player.py b/tests/components/persang_infrared/test_media_player.py new file mode 100644 index 000000000000..2d3f1d66b144 --- /dev/null +++ b/tests/components/persang_infrared/test_media_player.py @@ -0,0 +1,187 @@ +"""Tests for the Persang Infrared media player platform.""" + +from unittest.mock import patch + +from infrared_protocols.codes.persang.speaker import PersangSpeakerCode +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.media_player import ( + ATTR_MEDIA_VOLUME_MUTED, + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_VOLUME_DOWN, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_UP, + MediaPlayerState, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .conftest import MOCK_INFRARED_EMITTER_ENTITY_ID + +from tests.common import MockConfigEntry, mock_restore_cache, snapshot_platform +from tests.components.common import assert_availability_follows_source_entity +from tests.components.infrared.common import MockInfraredEmitterEntity + +MEDIA_PLAYER_ENTITY_ID = "media_player.persang_speaker" + + +@pytest.fixture +def platforms() -> list[Platform]: + """Return platforms to set up.""" + return [Platform.MEDIA_PLAYER] + + +@pytest.mark.usefixtures("init_integration") +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the media player entity is created with the correct attributes.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + device_entry = device_registry.async_get_device_by_identifier( + ("persang_infrared", mock_config_entry.entry_id), mock_config_entry.entry_id + ) + assert device_entry + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + for entity_entry in entity_entries: + assert entity_entry.device_id == device_entry.id + + +@pytest.mark.parametrize( + ("service", "expected_code", "expected_state"), + [ + (SERVICE_TURN_ON, PersangSpeakerCode.POWER, MediaPlayerState.ON), + (SERVICE_TURN_OFF, PersangSpeakerCode.POWER, MediaPlayerState.OFF), + (SERVICE_MEDIA_PLAY, PersangSpeakerCode.PLAY_PAUSE, MediaPlayerState.PLAYING), + (SERVICE_MEDIA_PAUSE, PersangSpeakerCode.PLAY_PAUSE, MediaPlayerState.PAUSED), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_state_changing_commands( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + service: str, + expected_code: PersangSpeakerCode, + expected_state: MediaPlayerState, +) -> None: + """Test commands that send a code and update the assumed state.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + service, + {ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID}, + blocking=True, + ) + + assert mock_infrared_emitter_entity.send_command_calls == [expected_code] + state = hass.states.get(MEDIA_PLAYER_ENTITY_ID) + assert state + assert state.state == expected_state + + +@pytest.mark.parametrize( + ("service", "expected_code"), + [ + (SERVICE_VOLUME_UP, PersangSpeakerCode.VOLUME_UP), + (SERVICE_VOLUME_DOWN, PersangSpeakerCode.VOLUME_DOWN), + (SERVICE_MEDIA_NEXT_TRACK, PersangSpeakerCode.NEXT), + (SERVICE_MEDIA_PREVIOUS_TRACK, PersangSpeakerCode.PREVIOUS), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_stateless_commands( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + service: str, + expected_code: PersangSpeakerCode, +) -> None: + """Test commands that only send a code.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + service, + {ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID}, + blocking=True, + ) + + assert mock_infrared_emitter_entity.send_command_calls == [expected_code] + + +@pytest.mark.parametrize("mute", [True, False]) +@pytest.mark.usefixtures("init_integration") +async def test_mute( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + mute: bool, +) -> None: + """Test muting sends the mute toggle and tracks the requested state.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_MUTE, + {ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, ATTR_MEDIA_VOLUME_MUTED: mute}, + blocking=True, + ) + + assert mock_infrared_emitter_entity.send_command_calls == [PersangSpeakerCode.MUTE] + state = hass.states.get(MEDIA_PLAYER_ENTITY_ID) + assert state + assert state.attributes[ATTR_MEDIA_VOLUME_MUTED] is mute + + +@pytest.mark.usefixtures("init_integration") +async def test_state_unknown_without_restored_state(hass: HomeAssistant) -> None: + """Test the entity starts as unknown when nothing was restored.""" + state = hass.states.get(MEDIA_PLAYER_ENTITY_ID) + assert state + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + "restored_state", + [ + MediaPlayerState.ON, + MediaPlayerState.OFF, + MediaPlayerState.PLAYING, + MediaPlayerState.PAUSED, + ], +) +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_restore_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + platforms: list[Platform], + restored_state: MediaPlayerState, +) -> None: + """Test the assumed state is restored across restarts.""" + mock_restore_cache(hass, [State(MEDIA_PLAYER_ENTITY_ID, restored_state)]) + + mock_config_entry.add_to_hass(hass) + with patch("homeassistant.components.persang_infrared.PLATFORMS", platforms): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(MEDIA_PLAYER_ENTITY_ID) + assert state + assert state.state == restored_state + + +@pytest.mark.usefixtures("init_integration") +async def test_media_player_availability_follows_ir_entity( + hass: HomeAssistant, +) -> None: + """Test the media player becomes unavailable when the IR entity is.""" + await assert_availability_follows_source_entity( + hass, MEDIA_PLAYER_ENTITY_ID, MOCK_INFRARED_EMITTER_ENTITY_ID + )