diff --git a/CODEOWNERS b/CODEOWNERS index d93afdbbdb837d..373d4bfa7a1617 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -453,6 +453,8 @@ CLAUDE.md @home-assistant/core /tests/components/ecovacs/ @mib1185 @edenhaus @Augar /homeassistant/components/ecowitt/ @pvizeli /tests/components/ecowitt/ @pvizeli +/homeassistant/components/edifier_infrared/ @abmantis +/tests/components/edifier_infrared/ @abmantis /homeassistant/components/efergy/ @tkdrob /tests/components/efergy/ @tkdrob /homeassistant/components/egardia/ @jeroenterheerdt diff --git a/homeassistant/components/edifier_infrared/__init__.py b/homeassistant/components/edifier_infrared/__init__.py new file mode 100644 index 00000000000000..a319fa77e39993 --- /dev/null +++ b/homeassistant/components/edifier_infrared/__init__.py @@ -0,0 +1,18 @@ +"""Edifier 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 Edifier 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 an Edifier IR config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/edifier_infrared/config_flow.py b/homeassistant/components/edifier_infrared/config_flow.py new file mode 100644 index 00000000000000..64e662e75e608a --- /dev/null +++ b/homeassistant/components/edifier_infrared/config_flow.py @@ -0,0 +1,77 @@ +"""Config flow for Edifier infrared integration.""" + +from typing import Any + +from infrared_protocols.codes.edifier.models import MODEL_TO_COMMAND_SET, EdifierModel +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.const import CONF_MODEL +from homeassistant.helpers.selector import ( + EntitySelector, + EntitySelectorConfig, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import CONF_COMMAND_SET, CONF_INFRARED_ENTITY_ID, DOMAIN + + +class EdifierIrConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle config flow for Edifier IR.""" + + VERSION = 1 + MINOR_VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step - select IR entity and speaker model.""" + 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: + infrared_entity_id = user_input[CONF_INFRARED_ENTITY_ID] + model = EdifierModel(user_input[CONF_MODEL]) + command_set = MODEL_TO_COMMAND_SET[model] + + await self.async_set_unique_id(f"{command_set}_{infrared_entity_id}") + self._abort_if_unique_id_configured() + + entity_name = infrared_entity_id + if state := self.hass.states.get(infrared_entity_id): + entity_name = state.name or infrared_entity_id + + return self.async_create_entry( + title=f"Edifier {model.value} via {entity_name}", + data={ + CONF_INFRARED_ENTITY_ID: infrared_entity_id, + CONF_MODEL: model.value, + CONF_COMMAND_SET: command_set.value, + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_INFRARED_ENTITY_ID): EntitySelector( + EntitySelectorConfig( + domain=INFRARED_DOMAIN, include_entities=emitter_entity_ids + ) + ), + vol.Required(CONF_MODEL): SelectSelector( + SelectSelectorConfig( + options=[model.value for model in EdifierModel], + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ), + ) diff --git a/homeassistant/components/edifier_infrared/const.py b/homeassistant/components/edifier_infrared/const.py new file mode 100644 index 00000000000000..057f71a7c51046 --- /dev/null +++ b/homeassistant/components/edifier_infrared/const.py @@ -0,0 +1,19 @@ +"""Constants for the Edifier infrared integration.""" + +from infrared_protocols.codes.edifier.r1280db import EdifierR1280DBCode +from infrared_protocols.codes.edifier.r1280t import EdifierR1280TCode +from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode +from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode +from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode + +DOMAIN = "edifier_infrared" +CONF_INFRARED_ENTITY_ID = "infrared_entity_id" +CONF_COMMAND_SET = "command_set" + +type EdifierCode = ( + EdifierR1700BTCode + | EdifierR1280DBCode + | EdifierR1280TCode + | EdifierS360DBCode + | EdifierRC20GCode +) diff --git a/homeassistant/components/edifier_infrared/entity.py b/homeassistant/components/edifier_infrared/entity.py new file mode 100644 index 00000000000000..6d483f56e781f8 --- /dev/null +++ b/homeassistant/components/edifier_infrared/entity.py @@ -0,0 +1,27 @@ +"""Common entity for Edifier infrared integration.""" + +from infrared_protocols.codes.edifier.models import EdifierModel + +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + + +class EdifierIrEntity(Entity): + """Edifier IR base entity providing common device info.""" + + _attr_has_entity_name = True + + def __init__( + self, entry: ConfigEntry, model: EdifierModel, unique_id_suffix: str + ) -> None: + """Initialize Edifier IR entity.""" + self._attr_unique_id = f"{entry.entry_id}_{unique_id_suffix}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name=f"Edifier {model.value}", + manufacturer="Edifier", + model=model.value, + ) diff --git a/homeassistant/components/edifier_infrared/manifest.json b/homeassistant/components/edifier_infrared/manifest.json new file mode 100644 index 00000000000000..38f3c96ce75bc7 --- /dev/null +++ b/homeassistant/components/edifier_infrared/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "edifier_infrared", + "name": "Edifier Infrared", + "codeowners": ["@abmantis"], + "config_flow": true, + "dependencies": ["infrared"], + "documentation": "https://www.home-assistant.io/integrations/edifier_infrared", + "integration_type": "device", + "iot_class": "assumed_state", + "quality_scale": "bronze" +} diff --git a/homeassistant/components/edifier_infrared/media_player.py b/homeassistant/components/edifier_infrared/media_player.py new file mode 100644 index 00000000000000..dfd3559d2c1fdd --- /dev/null +++ b/homeassistant/components/edifier_infrared/media_player.py @@ -0,0 +1,174 @@ +"""Media player platform for Edifier infrared integration.""" + +from infrared_protocols.codes.edifier.models import EdifierCommandSet, EdifierModel +from infrared_protocols.codes.edifier.r1280db import EdifierR1280DBCode +from infrared_protocols.codes.edifier.r1280t import EdifierR1280TCode +from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode +from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode +from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode + +from homeassistant.components.infrared import InfraredEmitterConsumerEntity +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerEntityFeature, + MediaPlayerState, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_MODEL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import CONF_COMMAND_SET, CONF_INFRARED_ENTITY_ID, EdifierCode +from .entity import EdifierIrEntity + +PARALLEL_UPDATES = 1 + + +COMMAND_SET_COMMANDS: dict[ + EdifierCommandSet, + dict[ + MediaPlayerEntityFeature, + tuple[EdifierCode | tuple[EdifierCode, ...], ...], + ], +] = { + EdifierCommandSet.R1700BT: { + MediaPlayerEntityFeature.TURN_ON: (EdifierR1700BTCode.POWER,), + MediaPlayerEntityFeature.TURN_OFF: (EdifierR1700BTCode.POWER,), + MediaPlayerEntityFeature.VOLUME_STEP: ( + (EdifierR1700BTCode.VOLUME_UP,), + (EdifierR1700BTCode.VOLUME_DOWN,), + ), + MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierR1700BTCode.MUTE,), + MediaPlayerEntityFeature.PLAY: (EdifierR1700BTCode.PLAY_PAUSE,), + MediaPlayerEntityFeature.PAUSE: (EdifierR1700BTCode.PLAY_PAUSE,), + MediaPlayerEntityFeature.NEXT_TRACK: (EdifierR1700BTCode.FORWARD,), + MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierR1700BTCode.BACK,), + }, + EdifierCommandSet.R1280DB: { + MediaPlayerEntityFeature.TURN_ON: (EdifierR1280DBCode.POWER,), + MediaPlayerEntityFeature.TURN_OFF: (EdifierR1280DBCode.POWER,), + MediaPlayerEntityFeature.VOLUME_STEP: ( + (EdifierR1280DBCode.VOLUME_UP,), + (EdifierR1280DBCode.VOLUME_DOWN,), + ), + MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierR1280DBCode.MUTE,), + MediaPlayerEntityFeature.PLAY: (EdifierR1280DBCode.PLAY_PAUSE,), + MediaPlayerEntityFeature.PAUSE: (EdifierR1280DBCode.PLAY_PAUSE,), + MediaPlayerEntityFeature.NEXT_TRACK: (EdifierR1280DBCode.FORWARD,), + MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierR1280DBCode.BACK,), + }, + EdifierCommandSet.R1280T: { + MediaPlayerEntityFeature.VOLUME_STEP: ( + (EdifierR1280TCode.VOLUME_UP,), + (EdifierR1280TCode.VOLUME_DOWN,), + ), + MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierR1280TCode.MUTE,), + }, + EdifierCommandSet.S360DB: { + MediaPlayerEntityFeature.TURN_ON: (EdifierS360DBCode.POWER,), + MediaPlayerEntityFeature.TURN_OFF: (EdifierS360DBCode.POWER,), + MediaPlayerEntityFeature.VOLUME_STEP: ( + (EdifierS360DBCode.VOLUME_UP,), + (EdifierS360DBCode.VOLUME_DOWN,), + ), + MediaPlayerEntityFeature.PLAY: (EdifierS360DBCode.PLAY_PAUSE,), + MediaPlayerEntityFeature.PAUSE: (EdifierS360DBCode.PLAY_PAUSE,), + MediaPlayerEntityFeature.NEXT_TRACK: (EdifierS360DBCode.NEXT,), + MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierS360DBCode.PREVIOUS,), + }, + EdifierCommandSet.RC20G: { + MediaPlayerEntityFeature.TURN_ON: (EdifierRC20GCode.POWER,), + MediaPlayerEntityFeature.TURN_OFF: (EdifierRC20GCode.POWER,), + MediaPlayerEntityFeature.VOLUME_STEP: ( + (EdifierRC20GCode.VOLUME_UP_LEFT, EdifierRC20GCode.VOLUME_UP_RIGHT), + (EdifierRC20GCode.VOLUME_DOWN_LEFT, EdifierRC20GCode.VOLUME_DOWN_RIGHT), + ), + MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierRC20GCode.MUTE,), + MediaPlayerEntityFeature.PLAY: (EdifierRC20GCode.PLAY_PAUSE,), + MediaPlayerEntityFeature.PAUSE: (EdifierRC20GCode.PLAY_PAUSE,), + MediaPlayerEntityFeature.NEXT_TRACK: (EdifierRC20GCode.FORWARD,), + MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierRC20GCode.PREVIOUS,), + }, +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Edifier IR media player.""" + infrared_entity_id = entry.data[CONF_INFRARED_ENTITY_ID] + command_set = EdifierCommandSet(entry.data[CONF_COMMAND_SET]) + model = EdifierModel(entry.data[CONF_MODEL]) + async_add_entities( + [EdifierIrMediaPlayer(entry, model, infrared_entity_id, command_set)] + ) + + +class EdifierIrMediaPlayer( + EdifierIrEntity, InfraredEmitterConsumerEntity, MediaPlayerEntity +): + """Edifier IR media player entity.""" + + _attr_name = None + _attr_assumed_state = True + _attr_device_class = MediaPlayerDeviceClass.SPEAKER + + def __init__( + self, + entry: ConfigEntry, + model: EdifierModel, + infrared_entity_id: str, + command_set: EdifierCommandSet, + ) -> None: + """Initialize Edifier IR media player.""" + super().__init__(entry, model, unique_id_suffix="media_player") + self._infrared_emitter_entity_id = infrared_entity_id + self._commands = COMMAND_SET_COMMANDS[command_set] + self._attr_state = MediaPlayerState.ON + self._attr_supported_features = MediaPlayerEntityFeature(0) + for feature in self._commands: + self._attr_supported_features |= feature + + async def _send_codes(self, *codes: EdifierCode) -> None: + """Send one or more IR commands.""" + for code in codes: + await self._send_command(code.to_command()) + + async def async_turn_on(self) -> None: + """Turn on the speaker.""" + await self._send_codes(*self._commands[MediaPlayerEntityFeature.TURN_ON]) + + async def async_turn_off(self) -> None: + """Turn off the speaker.""" + await self._send_codes(*self._commands[MediaPlayerEntityFeature.TURN_OFF]) + + async def async_volume_up(self) -> None: + """Send volume up command.""" + await self._send_codes(*self._commands[MediaPlayerEntityFeature.VOLUME_STEP][0]) + + async def async_volume_down(self) -> None: + """Send volume down command.""" + await self._send_codes(*self._commands[MediaPlayerEntityFeature.VOLUME_STEP][1]) + + async def async_mute_volume(self, mute: bool) -> None: + """Send mute command.""" + await self._send_codes(*self._commands[MediaPlayerEntityFeature.VOLUME_MUTE]) + + async def async_media_play(self) -> None: + """Send play command.""" + await self._send_codes(*self._commands[MediaPlayerEntityFeature.PLAY]) + + async def async_media_pause(self) -> None: + """Send pause command.""" + await self._send_codes(*self._commands[MediaPlayerEntityFeature.PAUSE]) + + async def async_media_next_track(self) -> None: + """Send next track command.""" + await self._send_codes(*self._commands[MediaPlayerEntityFeature.NEXT_TRACK]) + + async def async_media_previous_track(self) -> None: + """Send previous track command.""" + await self._send_codes(*self._commands[MediaPlayerEntityFeature.PREVIOUS_TRACK]) diff --git a/homeassistant/components/edifier_infrared/quality_scale.yaml b/homeassistant/components/edifier_infrared/quality_scale.yaml new file mode 100644 index 00000000000000..839ccdab17153f --- /dev/null +++ b/homeassistant/components/edifier_infrared/quality_scale.yaml @@ -0,0 +1,114 @@ +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-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + 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: done + 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: | + Discovery is not supported for infrared integrations. + 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: | + The media player entity is the primary entity and should be enabled by default. + entity-translations: done + exception-translations: + status: exempt + comment: | + This integration does not raise exceptions. + icon-translations: done + 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/edifier_infrared/strings.json b/homeassistant/components/edifier_infrared/strings.json new file mode 100644 index 00000000000000..332ec2ddff086d --- /dev/null +++ b/homeassistant/components/edifier_infrared/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "This Edifier device 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_entity_id": "IR transmitter", + "model": "Speaker model" + }, + "data_description": { + "infrared_entity_id": "Select the infrared transmitter entity to use.", + "model": "Choose your Edifier speaker model from the list." + }, + "description": "Configure your Edifier speaker for IR control.", + "title": "Set up Edifier IR speaker" + } + } + } +} diff --git a/homeassistant/components/incomfort/__init__.py b/homeassistant/components/incomfort/__init__.py index d6375024653e03..7871483f73cb85 100644 --- a/homeassistant/components/incomfort/__init__.py +++ b/homeassistant/components/incomfort/__init__.py @@ -1,21 +1,12 @@ """Support for an Intergas boiler via an InComfort/Intouch Lan2RF gateway.""" -from aiohttp import ClientResponseError -from incomfortclient import InvalidGateway, InvalidHeaterList +from incomfortclient import Gateway as InComfortGateway -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers import device_registry as dr +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN -from .coordinator import ( - InComfortConfigEntry, - InComfortData, - InComfortDataCoordinator, - async_connect_gateway, -) -from .errors import InComfortTimeout, InComfortUnknownError, NoHeaters, NotFound +from .coordinator import InComfortConfigEntry, InComfortDataCoordinator PLATFORMS = ( Platform.WATER_HEATER, @@ -27,75 +18,16 @@ INTEGRATION_TITLE = "Intergas InComfort/Intouch Lan2RF gateway" -@callback -def async_cleanup_stale_devices( - hass: HomeAssistant, - entry: InComfortConfigEntry, - data: InComfortData, - gateway_device: dr.DeviceEntry, -) -> None: - """Cleanup stale heater devices and climates.""" - heater_serial_numbers = {heater.serial_no for heater in data.heaters} - device_registry = dr.async_get(hass) - device_entries = device_registry.devices.get_devices_for_config_entry_id( - entry.entry_id - ) - stale_heater_serial_numbers: list[str] = [ - device_entry.serial_number - for device_entry in device_entries - if device_entry.id != gateway_device.id - and device_entry.serial_number is not None - and device_entry.serial_number not in heater_serial_numbers - ] - if not stale_heater_serial_numbers: - return - cleanup_devices: list[str] = [] - # Find stale heater and climate devices - for serial_number in stale_heater_serial_numbers: - cleanup_list = [f"{serial_number}_{index}" for index in range(1, 4)] - cleanup_list.append(serial_number) - cleanup_identifiers = [{(DOMAIN, cleanup_id)} for cleanup_id in cleanup_list] - cleanup_devices.extend( - device_entry.id - for device_entry in device_entries - if device_entry.identifiers in cleanup_identifiers - ) - for device_id in cleanup_devices: - device_registry.async_remove_device(device_id) - - async def async_setup_entry(hass: HomeAssistant, entry: InComfortConfigEntry) -> bool: """Set up a config entry.""" - try: - data = await async_connect_gateway(hass, dict(entry.data)) - for heater in data.heaters: - await heater.update() - except InvalidHeaterList as exc: - raise NoHeaters from exc - except InvalidGateway as exc: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, translation_key="incorrect_credentials" - ) from exc - except ClientResponseError as exc: - if exc.status == 404: - raise NotFound from exc - raise InComfortUnknownError from exc - except TimeoutError as exc: - raise InComfortTimeout from exc - # Register discovered gateway device - device_registry = dr.async_get(hass) - gateway_device = device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, entry.entry_id)}, - connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)} - if entry.unique_id is not None - else set(), - manufacturer="Intergas", - name="RFGateway", + credentials = dict(entry.data) + hostname = credentials.pop(CONF_HOST) + client = InComfortGateway( + hostname, **credentials, session=async_get_clientsession(hass) ) - async_cleanup_stale_devices(hass, entry, data, gateway_device) - coordinator = InComfortDataCoordinator(hass, entry, data) + + coordinator = InComfortDataCoordinator(hass, entry, client) entry.runtime_data = coordinator await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/incomfort/config_flow.py b/homeassistant/components/incomfort/config_flow.py index a660c8b8e0a54c..0c39db2b92362c 100644 --- a/homeassistant/components/incomfort/config_flow.py +++ b/homeassistant/components/incomfort/config_flow.py @@ -4,7 +4,11 @@ import logging from typing import Any, override -from incomfortclient import InvalidGateway, InvalidHeaterList +from incomfortclient import ( + Gateway as InComfortGateway, + InvalidGateway, + InvalidHeaterList, +) import voluptuous as vol from homeassistant.config_entries import ( @@ -17,6 +21,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.selector import ( BooleanSelector, @@ -28,7 +33,7 @@ from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import CONF_LEGACY_SETPOINT_STATUS, DOMAIN -from .coordinator import InComfortConfigEntry, async_connect_gateway +from .coordinator import InComfortConfigEntry _LOGGER = logging.getLogger(__name__) TITLE = "Intergas InComfort/Intouch Lan2RF gateway" @@ -81,7 +86,13 @@ async def async_try_connect_gateway( ) -> dict[str, str] | None: """Try to connect to the Lan2RF gateway.""" try: - await async_connect_gateway(hass, config) + client = InComfortGateway( + hostname=config[CONF_HOST], + username=config.get(CONF_USERNAME), + password=config.get(CONF_PASSWORD), + session=async_get_clientsession(hass), + ) + await client.heaters() except InvalidGateway: return {"base": "auth_error"} except InvalidHeaterList: diff --git a/homeassistant/components/incomfort/coordinator.py b/homeassistant/components/incomfort/coordinator.py index 52598bc27b5fcb..12f1255bb05134 100644 --- a/homeassistant/components/incomfort/coordinator.py +++ b/homeassistant/components/incomfort/coordinator.py @@ -2,21 +2,22 @@ from dataclasses import dataclass, field from datetime import timedelta +from http import HTTPStatus import logging -from typing import Any, override +from typing import override from aiohttp import ClientResponseError from incomfortclient import ( Gateway as InComfortGateway, Heater as InComfortHeater, + InvalidGateway, InvalidHeaterList, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -36,20 +37,41 @@ class InComfortData: heaters: list[InComfortHeater] = field(default_factory=list) -async def async_connect_gateway( +@callback +def async_cleanup_stale_devices( hass: HomeAssistant, - entry_data: dict[str, Any], -) -> InComfortData: - """Validate the configuration.""" - credentials = dict(entry_data) - hostname = credentials.pop(CONF_HOST) - - client = InComfortGateway( - hostname, **credentials, session=async_get_clientsession(hass) + entry: InComfortConfigEntry, + data: InComfortData, + gateway_device: dr.DeviceEntry, +) -> None: + """Cleanup stale heater devices and climates.""" + heater_serial_numbers = {heater.serial_no for heater in data.heaters} + device_registry = dr.async_get(hass) + device_entries = device_registry.devices.get_devices_for_config_entry_id( + entry.entry_id ) - heaters = await client.heaters() - - return InComfortData(client=client, heaters=heaters) + stale_heater_serial_numbers: list[str] = [ + device_entry.serial_number + for device_entry in device_entries + if device_entry.id != gateway_device.id + and device_entry.serial_number is not None + and device_entry.serial_number not in heater_serial_numbers + ] + if not stale_heater_serial_numbers: + return + cleanup_devices: list[str] = [] + # Find stale heater and climate devices + for serial_number in stale_heater_serial_numbers: + cleanup_list = [f"{serial_number}_{index}" for index in range(1, 4)] + cleanup_list.append(serial_number) + cleanup_identifiers = [{(DOMAIN, cleanup_id)} for cleanup_id in cleanup_list] + cleanup_devices.extend( + device_entry.id + for device_entry in device_entries + if device_entry.identifiers in cleanup_identifiers + ) + for device_id in cleanup_devices: + device_registry.async_remove_device(device_id) class InComfortDataCoordinator(DataUpdateCoordinator[InComfortData]): @@ -61,10 +83,9 @@ def __init__( self, hass: HomeAssistant, config_entry: InComfortConfigEntry, - incomfort_data: InComfortData, + client: InComfortGateway, ) -> None: """Initialize coordinator.""" - self.unique_id = config_entry.unique_id super().__init__( hass, _LOGGER, @@ -72,28 +93,65 @@ def __init__( name="InComfort datacoordinator", update_interval=timedelta(seconds=UPDATE_INTERVAL), ) - self.incomfort_data = incomfort_data + self.client = client + self.unique_id = config_entry.unique_id @override async def _async_update_data(self) -> InComfortData: - """Fetch data from API endpoint.""" + """Fetch data from Incomfort.""" try: - for heater in self.incomfort_data.heaters: + heaters = await self.client.heaters() + for heater in heaters: await heater.update() + except InvalidGateway as exc: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from exc + except TimeoutError as exc: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="timeout_error", + ) from exc except ClientResponseError as exc: - if exc.status == 401: + if exc.status == HTTPStatus.UNAUTHORIZED: raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, translation_key="incorrect_credentials" + translation_domain=DOMAIN, + translation_key="invalid_auth", ) from exc + _LOGGER.exception("Error communicating with InComfort gateway") raise UpdateFailed( translation_domain=DOMAIN, - translation_key="update_failed_with_error_message", - translation_placeholders={"error": exc.message}, + translation_key="unknown", ) from exc except InvalidHeaterList as exc: raise UpdateFailed( translation_domain=DOMAIN, - translation_key="update_failed_with_error_message", - translation_placeholders={"error": exc.message}, + translation_key="no_heaters", ) from exc - return self.incomfort_data + + incomfort_data = InComfortData( + client=self.client, + heaters=heaters, + ) + + # Register discovered gateway device + # Respect this as it is. Maybe later... + device_registry = dr.async_get(self.hass) + gateway_device = device_registry.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + identifiers={(DOMAIN, self.config_entry.entry_id)}, + connections={(dr.CONNECTION_NETWORK_MAC, self.config_entry.unique_id)} + if self.config_entry.unique_id is not None + else set(), + manufacturer="Intergas", + name="RFGateway", + ) + async_cleanup_stale_devices( + self.hass, + self.config_entry, + incomfort_data, + gateway_device, + ) + + return incomfort_data diff --git a/homeassistant/components/incomfort/diagnostics.py b/homeassistant/components/incomfort/diagnostics.py index 29ba123bf3fb36..a6115b29e324b7 100644 --- a/homeassistant/components/incomfort/diagnostics.py +++ b/homeassistant/components/incomfort/diagnostics.py @@ -27,15 +27,14 @@ def _async_get_diagnostics( redacted_config = async_redact_data(entry.data | entry.options, REDACT_CONFIG) coordinator = entry.runtime_data - nr_heaters = len(coordinator.incomfort_data.heaters) + nr_heaters = len(coordinator.data.heaters) status: dict[str, Any] = { - f"heater_{n}": coordinator.incomfort_data.heaters[n].status - for n in range(nr_heaters) + f"heater_{n}": coordinator.data.heaters[n].status for n in range(nr_heaters) } for n in range(nr_heaters): status[f"heater_{n}"]["rooms"] = { - m: dict(coordinator.incomfort_data.heaters[n].rooms[m].status) - for m in range(len(coordinator.incomfort_data.heaters[n].rooms)) + m: dict(coordinator.data.heaters[n].rooms[m].status) + for m in range(len(coordinator.data.heaters[n].rooms)) } return { "config": redacted_config, diff --git a/homeassistant/components/incomfort/errors.py b/homeassistant/components/incomfort/errors.py deleted file mode 100644 index c367916d6c7761..00000000000000 --- a/homeassistant/components/incomfort/errors.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Exceptions raised by Intergas InComfort integration.""" - -from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError - -from .const import DOMAIN - - -class NotFound(HomeAssistantError): - """Raise exception if no Lan2RF Gateway was found.""" - - translation_domain = DOMAIN - translation_key = "not_found" - - -class NoHeaters(ConfigEntryNotReady): - """Raise exception if no heaters are found.""" - - translation_domain = DOMAIN - translation_key = "no_heaters" - - -class InComfortTimeout(ConfigEntryNotReady): - """Raise exception if no heaters are found.""" - - translation_domain = DOMAIN - translation_key = "timeout_error" - - -class InComfortUnknownError(ConfigEntryNotReady): - """Raise exception if no heaters are found.""" - - translation_domain = DOMAIN - translation_key = "unknown" diff --git a/homeassistant/components/incomfort/strings.json b/homeassistant/components/incomfort/strings.json index 27b0c3feeabe74..99aa18e5d3aaf8 100644 --- a/homeassistant/components/incomfort/strings.json +++ b/homeassistant/components/incomfort/strings.json @@ -131,7 +131,9 @@ } }, "exceptions": { - "incorrect_credentials": { "message": "Incorrect credentials." }, + "invalid_auth": { + "message": "[%key:component::incomfort::config::error::auth_error%]" + }, "no_heaters": { "message": "[%key:component::incomfort::config::error::no_heaters%]" }, diff --git a/homeassistant/components/infrared/manifest.json b/homeassistant/components/infrared/manifest.json index 18cf5acb5c9cac..e58ea9bada17c3 100644 --- a/homeassistant/components/infrared/manifest.json +++ b/homeassistant/components/infrared/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/infrared", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["infrared-protocols==5.8.1"] + "requirements": ["infrared-protocols==6.0.0"] } diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index 9456dee1e60ea1..d29370d58d8baa 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -14,7 +14,7 @@ "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], "requirements": [ - "onvif-zeep-async==4.1.1", + "onvif-zeep-async==4.2.0", "onvif_parsers==2.3.0", "WSDiscovery==2.1.2" ] diff --git a/homeassistant/components/reolink/diagnostics.py b/homeassistant/components/reolink/diagnostics.py index b6daa0f6019b83..84d7bb59a4732f 100644 --- a/homeassistant/components/reolink/diagnostics.py +++ b/homeassistant/components/reolink/diagnostics.py @@ -41,6 +41,7 @@ async def async_get_config_entry_diagnostics( "HTTP(S) port": api.port, "Baichuan port": api.baichuan.port, "Baichuan only": api.baichuan_only, + "Baichuan connection": api.baichuan.connection_type.value, "WiFi connection": api.wifi_connection(), "WiFi signal": api.wifi_signal(), "RTMP enabled": api.rtmp_enabled, @@ -48,10 +49,15 @@ async def async_get_config_entry_diagnostics( "ONVIF enabled": api.onvif_enabled, "event connection": host.event_connection, "stream protocol": api.protocol, + "is NVR": api.is_nvr, + "is Hub": api.is_hub, + "is Battery": api.is_battery, "channels": api.channels, "stream channels": api.stream_channels, "IPC cams": ipc_cam, "Chimes": chimes, + "Broken cmds": api.broken_cmds, + "Baichuan fallbacks": api.baichuan_cmds, "capabilities": api.capabilities, "cmd list": host.update_cmd, "firmware ch list": host.firmware_ch_list, diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index cd9bca877ad331..9f9fd09fbf342a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -186,6 +186,7 @@ "econet", "ecovacs", "ecowitt", + "edifier_infrared", "edl21", "efergy", "egauge", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index faea7ffac554eb..882682864a4758 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1654,6 +1654,12 @@ "config_flow": true, "iot_class": "local_push" }, + "edifier_infrared": { + "name": "Edifier Infrared", + "integration_type": "device", + "config_flow": true, + "iot_class": "assumed_state" + }, "edimax": { "name": "Edimax", "integration_type": "hub", diff --git a/requirements.txt b/requirements.txt index 358c4ea0084f4d..5fa2a4e29975cd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,7 +30,7 @@ home-assistant-bluetooth==2.0.0 home-assistant-intents==2026.6.1 httpx==0.28.1 ifaddr==0.2.0 -infrared-protocols==5.8.1 +infrared-protocols==6.0.0 Jinja2==3.1.6 lru-dict==1.4.1 mutagen==1.47.0 diff --git a/requirements_all.txt b/requirements_all.txt index fe1e617cc97a64..1bdf699a859689 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1362,7 +1362,7 @@ influxdb-client==1.50.0 influxdb==5.3.1 # homeassistant.components.infrared -infrared-protocols==5.8.1 +infrared-protocols==6.0.0 # homeassistant.components.inkbird inkbird-ble==1.4.4 @@ -1740,7 +1740,7 @@ ondilo==0.5.0 onedrive-personal-sdk==0.1.7 # homeassistant.components.onvif -onvif-zeep-async==4.1.1 +onvif-zeep-async==4.2.0 # homeassistant.components.onvif onvif_parsers==2.3.0 diff --git a/tests/components/edifier_infrared/__init__.py b/tests/components/edifier_infrared/__init__.py new file mode 100644 index 00000000000000..7928f9dd23586d --- /dev/null +++ b/tests/components/edifier_infrared/__init__.py @@ -0,0 +1 @@ +"""Tests for the Edifier Infrared integration.""" diff --git a/tests/components/edifier_infrared/conftest.py b/tests/components/edifier_infrared/conftest.py new file mode 100644 index 00000000000000..25eec8b6654a05 --- /dev/null +++ b/tests/components/edifier_infrared/conftest.py @@ -0,0 +1,99 @@ +"""Common fixtures for the Edifier Infrared tests.""" + +from collections.abc import Generator +from unittest.mock import patch + +from infrared_protocols.codes.edifier.models import EdifierCommandSet, EdifierModel +import pytest + +from homeassistant.components.edifier_infrared import PLATFORMS +from homeassistant.components.edifier_infrared.const import ( + CONF_COMMAND_SET, + CONF_INFRARED_ENTITY_ID, + DOMAIN, +) +from homeassistant.const import CONF_MODEL, 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="Edifier R1700BT via Test IR emitter", + data={ + CONF_INFRARED_ENTITY_ID: MOCK_INFRARED_EMITTER_ENTITY_ID, + CONF_MODEL: EdifierModel.R1700BT.value, + CONF_COMMAND_SET: EdifierCommandSet.R1700BT.value, + }, + unique_id=f"r1700bt_{MOCK_INFRARED_EMITTER_ENTITY_ID}", + ) + + +@pytest.fixture +def platforms() -> list[Platform]: + """Return platforms to set up.""" + return PLATFORMS + + +@pytest.fixture +def mock_edifier_code_to_command() -> Generator[None]: + """Patch Edifier *Code.to_command to return the code enum directly. + + This allows tests to assert on the high-level code enum value + rather than the raw NEC timings. + """ + with ( + patch( + "infrared_protocols.codes.edifier.r1700bt.EdifierR1700BTCode.to_command", + autospec=True, + side_effect=lambda self: self, + ), + patch( + "infrared_protocols.codes.edifier.r1280db.EdifierR1280DBCode.to_command", + autospec=True, + side_effect=lambda self: self, + ), + patch( + "infrared_protocols.codes.edifier.r1280t.EdifierR1280TCode.to_command", + autospec=True, + side_effect=lambda self: self, + ), + patch( + "infrared_protocols.codes.edifier.s360db.EdifierS360DBCode.to_command", + autospec=True, + side_effect=lambda self: self, + ), + patch( + "infrared_protocols.codes.edifier.rc20g.EdifierRC20GCode.to_command", + autospec=True, + side_effect=lambda self: self, + ), + ): + yield + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + mock_edifier_code_to_command: None, + platforms: list[Platform], +) -> MockConfigEntry: + """Set up the Edifier Infrared integration for testing.""" + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.edifier_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/edifier_infrared/snapshots/test_media_player.ambr b/tests/components/edifier_infrared/snapshots/test_media_player.ambr new file mode 100644 index 00000000000000..40fc761338a80c --- /dev/null +++ b/tests/components/edifier_infrared/snapshots/test_media_player.ambr @@ -0,0 +1,55 @@ +# serializer version: 1 +# name: test_entities[media_player.edifier_r1700bt-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.edifier_r1700bt', + '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': 'edifier_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '01JTEST0000000000000000000_media_player', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[media_player.edifier_r1700bt-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'assumed_state': True, + 'device_class': 'speaker', + 'friendly_name': 'Edifier R1700BT', + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.edifier_r1700bt', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/edifier_infrared/test_config_flow.py b/tests/components/edifier_infrared/test_config_flow.py new file mode 100644 index 00000000000000..f12b3b33899e39 --- /dev/null +++ b/tests/components/edifier_infrared/test_config_flow.py @@ -0,0 +1,131 @@ +"""Tests for the Edifier Infrared config flow.""" + +from infrared_protocols.codes.edifier.models import EdifierCommandSet, EdifierModel +import pytest + +from homeassistant.components.edifier_infrared.const import ( + CONF_COMMAND_SET, + CONF_INFRARED_ENTITY_ID, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_MODEL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry +from tests.components.infrared import EMITTER_ENTITY_ID + + +@pytest.mark.parametrize( + ("model", "expected_command_set"), + [ + (EdifierModel.R1700BT, EdifierCommandSet.R1700BT), + (EdifierModel.R1280DB, EdifierCommandSet.R1280DB), + (EdifierModel.R1280T, EdifierCommandSet.R1280T), + (EdifierModel.S360DB, EdifierCommandSet.S360DB), + (EdifierModel.RC20G, EdifierCommandSet.RC20G), + ], +) +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_user_flow_success( + hass: HomeAssistant, + model: EdifierModel, + expected_command_set: EdifierCommandSet, +) -> None: + """Test successful user config flow for each command set.""" + 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_ENTITY_ID: EMITTER_ENTITY_ID, + CONF_MODEL: model.value, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"Edifier {model.value} via Test IR emitter" + assert result["data"] == { + CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID, + CONF_MODEL: model.value, + CONF_COMMAND_SET: expected_command_set.value, + } + assert ( + result["result"].unique_id + == f"{expected_command_set.value}_{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 entry 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_ENTITY_ID: EMITTER_ENTITY_ID, + CONF_MODEL: EdifierModel.R1700BT.value, + }, + ) + + 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" + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +@pytest.mark.parametrize( + ("entity_name", "expected_title"), + [ + (None, "Edifier R1700BT via Test IR emitter"), + ("Living room IR", "Edifier R1700BT via Living room IR"), + ], +) +async def test_user_flow_title_from_entity_name( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + entity_name: str | None, + expected_title: str, +) -> None: + """Test config entry title uses the entity name.""" + entity_registry.async_update_entity(EMITTER_ENTITY_ID, name=entity_name) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID, + CONF_MODEL: EdifierModel.R1700BT.value, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == expected_title diff --git a/tests/components/edifier_infrared/test_init.py b/tests/components/edifier_infrared/test_init.py new file mode 100644 index 00000000000000..8e2770e8ca0d86 --- /dev/null +++ b/tests/components/edifier_infrared/test_init.py @@ -0,0 +1,19 @@ +"""Tests for the Edifier 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/edifier_infrared/test_media_player.py b/tests/components/edifier_infrared/test_media_player.py new file mode 100644 index 00000000000000..5fb1779ebcb04d --- /dev/null +++ b/tests/components/edifier_infrared/test_media_player.py @@ -0,0 +1,143 @@ +"""Tests for the Edifier Infrared media player platform.""" + +from infrared_protocols.codes.edifier.models import EdifierCommandSet, EdifierModel +from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode +from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.edifier_infrared.const import ( + CONF_COMMAND_SET, + CONF_INFRARED_ENTITY_ID, + DOMAIN, +) +from homeassistant.components.media_player import ( + 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, +) +from homeassistant.const import ATTR_ENTITY_ID, CONF_MODEL, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform +from tests.components.common import assert_availability_follows_source_entity +from tests.components.infrared import EMITTER_ENTITY_ID +from tests.components.infrared.common import MockInfraredEmitterEntity + +MEDIA_PLAYER_ENTITY_ID = "media_player.edifier_r1700bt" + + +@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, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the media player entity is created with correct attributes.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("service", "service_data", "expected_code"), + [ + (SERVICE_TURN_ON, {}, EdifierR1700BTCode.POWER), + (SERVICE_TURN_OFF, {}, EdifierR1700BTCode.POWER), + (SERVICE_VOLUME_UP, {}, EdifierR1700BTCode.VOLUME_UP), + (SERVICE_VOLUME_DOWN, {}, EdifierR1700BTCode.VOLUME_DOWN), + (SERVICE_VOLUME_MUTE, {"is_volume_muted": True}, EdifierR1700BTCode.MUTE), + (SERVICE_MEDIA_PLAY, {}, EdifierR1700BTCode.PLAY_PAUSE), + (SERVICE_MEDIA_PAUSE, {}, EdifierR1700BTCode.PLAY_PAUSE), + (SERVICE_MEDIA_NEXT_TRACK, {}, EdifierR1700BTCode.FORWARD), + (SERVICE_MEDIA_PREVIOUS_TRACK, {}, EdifierR1700BTCode.BACK), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_media_player_action_sends_correct_code( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + service: str, + service_data: dict[str, bool], + expected_code: EdifierR1700BTCode, +) -> None: + """Test each media player action sends the correct IR code.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + service, + {ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, **service_data}, + blocking=True, + ) + + assert len(mock_infrared_emitter_entity.send_command_calls) == 1 + assert mock_infrared_emitter_entity.send_command_calls[0] == expected_code + + +@pytest.mark.parametrize( + "mock_config_entry", + [ + MockConfigEntry( + domain=DOMAIN, + entry_id="01JTEST0000000000000000001", + title="Edifier RC20G via Test IR emitter", + data={ + CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID, + CONF_MODEL: EdifierModel.RC20G.value, + CONF_COMMAND_SET: EdifierCommandSet.RC20G.value, + }, + unique_id=f"rc20g_{EMITTER_ENTITY_ID}", + ) + ], +) +@pytest.mark.parametrize( + ("service", "expected_codes"), + [ + ( + SERVICE_VOLUME_UP, + (EdifierRC20GCode.VOLUME_UP_LEFT, EdifierRC20GCode.VOLUME_UP_RIGHT), + ), + ( + SERVICE_VOLUME_DOWN, + (EdifierRC20GCode.VOLUME_DOWN_LEFT, EdifierRC20GCode.VOLUME_DOWN_RIGHT), + ), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_rc20g_volume_sends_left_and_right_codes( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + service: str, + expected_codes: tuple[EdifierRC20GCode, ...], +) -> None: + """Test that RC20G volume up/down send both left and right channel codes.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + service, + {ATTR_ENTITY_ID: "media_player.edifier_rc20g"}, + blocking=True, + ) + + assert tuple(mock_infrared_emitter_entity.send_command_calls) == expected_codes + + +@pytest.mark.usefixtures("init_integration") +async def test_media_player_availability_follows_ir_entity( + hass: HomeAssistant, +) -> None: + """Test media player becomes unavailable when IR entity is unavailable.""" + await assert_availability_follows_source_entity( + hass, MEDIA_PLAYER_ENTITY_ID, EMITTER_ENTITY_ID + ) diff --git a/tests/components/incomfort/conftest.py b/tests/components/incomfort/conftest.py index f557e086fcf20e..6afd89644a5e4b 100644 --- a/tests/components/incomfort/conftest.py +++ b/tests/components/incomfort/conftest.py @@ -171,9 +171,15 @@ async def update(self) -> None: setattr(self, key, value) self.rooms = [MockRoom()] - with patch( - "homeassistant.components.incomfort.coordinator.InComfortGateway", MagicMock() - ) as patch_gateway: + mock_cls = MagicMock() + with ( + patch( + "homeassistant.components.incomfort.InComfortGateway", mock_cls + ) as patch_gateway, + patch( + "homeassistant.components.incomfort.config_flow.InComfortGateway", mock_cls + ), + ): patch_gateway().heaters = AsyncMock() patch_gateway().heaters.return_value = [MockHeater()] patch_gateway().mock_heater_status = mock_heater_status diff --git a/tests/components/incomfort/test_climate.py b/tests/components/incomfort/test_climate.py index a4c97d88e34888..43d997daa250a0 100644 --- a/tests/components/incomfort/test_climate.py +++ b/tests/components/incomfort/test_climate.py @@ -6,7 +6,6 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components import climate -from homeassistant.components.incomfort.coordinator import InComfortData from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant @@ -77,7 +76,7 @@ async def test_target_temp( state = hass.states.get("climate.thermostat_1") assert state is not None - incomfort_data: InComfortData = mock_config_entry.runtime_data.incomfort_data + incomfort_data = mock_config_entry.runtime_data.data with patch.object( incomfort_data.heaters[0].rooms[0], "set_override", AsyncMock() diff --git a/tests/components/incomfort/test_init.py b/tests/components/incomfort/test_init.py index 92ce0afa448647..3fdd0c891a5cbf 100644 --- a/tests/components/incomfort/test_init.py +++ b/tests/components/incomfort/test_init.py @@ -2,14 +2,14 @@ from datetime import timedelta from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch from aiohttp import ClientResponseError, RequestInfo from freezegun.api import FrozenDateTimeFactory from incomfortclient import InvalidGateway, InvalidHeaterList import pytest -from homeassistant.components.incomfort import DOMAIN +from homeassistant.components.incomfort.const import DOMAIN from homeassistant.components.incomfort.coordinator import UPDATE_INTERVAL from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE @@ -188,7 +188,7 @@ async def test_coordinator_update_fails( None, status=404, ), - ConfigEntryState.SETUP_ERROR, + ConfigEntryState.SETUP_RETRY, ), ( ClientResponseError( @@ -215,11 +215,8 @@ async def test_entry_setup_fails( config_entry_state: ConfigEntryState, ) -> None: """Test the incomfort coordinator entry setup fails.""" - with patch( - "homeassistant.components.incomfort.async_connect_gateway", - AsyncMock(side_effect=exc), - ): - await hass.config_entries.async_setup(mock_config_entry.entry_id) + mock_incomfort().heaters.side_effect = exc + await hass.config_entries.async_setup(mock_config_entry.entry_id) state = hass.states.get("sensor.boiler_pressure") assert state is None assert mock_config_entry.state is config_entry_state diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 025f3f0a176a72..37a2e98dcf1fa0 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -130,6 +130,8 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.firmware_update_available.return_value = False host_mock.session_active = True host_mock.timeout = 60 + host_mock.broken_cmds = ["GetManualRec"] + host_mock.baichuan_cmds = ["GetPtzCurPos"] host_mock.renewtimer.return_value = 600 host_mock.wifi_connection.return_value = False host_mock.wifi_signal.return_value = -45 diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index 262e4bc63fffaa..6bbaa50ffa5c63 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -10,8 +10,15 @@ 'pushAlarm': 7, }), }), + 'Baichuan connection': 'tcp', + 'Baichuan fallbacks': list([ + 'GetPtzCurPos', + ]), 'Baichuan only': False, 'Baichuan port': 5678, + 'Broken cmds': list([ + 'GetManualRec', + ]), 'Chimes': dict({ '12345678': dict({ 'channel': 0, @@ -215,6 +222,9 @@ ]), 'firmware version': 'v1.0.0.0.0.0000', 'hardware version': 'IPC_00000', + 'is Battery': False, + 'is Hub': False, + 'is NVR': True, 'model': 'RLN8-410', 'stream channels': list([ 0,