From 55ba9f1825dfa8b908fadc9708567ed1a3424b25 Mon Sep 17 00:00:00 2001 From: johnmschoonover Date: Thu, 30 Oct 2025 22:50:27 -0500 Subject: [PATCH 1/3] Add config flow for Sony Projector integration --- .../components/sony_projector/AGENTS.md | 6 + .../components/sony_projector/config_flow.py | 127 +++++++++++++++ .../components/sony_projector/manifest.json | 1 + .../components/sony_projector/strings.json | 25 +++ .../sony_projector/translations/en.json | 25 +++ .../sony_projector/test_config_flow.py | 147 ++++++++++++++++++ 6 files changed, 331 insertions(+) create mode 100644 homeassistant/components/sony_projector/AGENTS.md create mode 100644 homeassistant/components/sony_projector/config_flow.py create mode 100644 homeassistant/components/sony_projector/strings.json create mode 100644 homeassistant/components/sony_projector/translations/en.json create mode 100644 tests/components/sony_projector/test_config_flow.py diff --git a/homeassistant/components/sony_projector/AGENTS.md b/homeassistant/components/sony_projector/AGENTS.md new file mode 100644 index 00000000000000..e153d16764bc74 --- /dev/null +++ b/homeassistant/components/sony_projector/AGENTS.md @@ -0,0 +1,6 @@ +# Agent Instructions for `sony_projector` + +- Always run network operations for this integration via `async_add_executor_job` to avoid blocking the event loop. +- Use the projector host as the config entry unique ID and prevent duplicate flows based on the host. +- Do not ask users to provide a custom name during configuration; rely on the built-in default title instead. +- When writing tests, patch `homeassistant.components.sony_projector.config_flow.pysdcp.Projector` to avoid real I/O. diff --git a/homeassistant/components/sony_projector/config_flow.py b/homeassistant/components/sony_projector/config_flow.py new file mode 100644 index 00000000000000..114cfafccef995 --- /dev/null +++ b/homeassistant/components/sony_projector/config_flow.py @@ -0,0 +1,127 @@ +"""Config flow for the Sony Projector integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +import pysdcp +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +DOMAIN = "sony_projector" +DEFAULT_TITLE = "Sony Projector" + +STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) + + +async def _async_validate_input(hass: HomeAssistant, data: Mapping[str, Any]) -> None: + """Validate the host information provided by the user.""" + + projector = pysdcp.Projector(data[CONF_HOST]) + + try: + await hass.async_add_executor_job(projector.get_power) + except ConnectionError as err: + raise CannotConnect from err + except OSError as err: + raise CannotConnect from err + except Exception as err: # noqa: BLE001 + raise UnknownError from err + + +class SonyProjectorConfigFlow(ConfigFlow, domain=DOMAIN): + """Config flow for Sony Projector.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: Mapping[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initiated by the user.""" + + errors: dict[str, str] = {} + + if user_input is not None: + host = user_input[CONF_HOST] + + try: + await _async_validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except UnknownError: + errors["base"] = "unknown" + else: + await self.async_set_unique_id(host) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + + return self.async_create_entry( + title=DEFAULT_TITLE, + data={CONF_HOST: host}, + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, import_data: Mapping[str, Any]) -> ConfigFlowResult: + """Handle import from YAML.""" + + host = import_data[CONF_HOST] + + await _async_validate_input(self.hass, import_data) + + await self.async_set_unique_id(host) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + self._async_abort_entries_match({CONF_HOST: host}) + + data = {CONF_HOST: host} + if CONF_NAME in import_data: + data[CONF_NAME] = import_data[CONF_NAME] + + return self.async_create_entry( + title=DEFAULT_TITLE, + data=data, + ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> ConfigFlowResult: + """Handle reauthentication flow.""" + + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: Mapping[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauthentication by testing connectivity.""" + + errors: dict[str, str] = {} + entry = self._get_reauth_entry() + host = entry.data[CONF_HOST] + + if user_input is not None: + try: + await _async_validate_input(self.hass, entry.data) + except CannotConnect: + errors["base"] = "cannot_connect" + except UnknownError: + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort(entry, data_updates=entry.data) + + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={"host": host}, + errors=errors, + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class UnknownError(HomeAssistantError): + """Error to indicate an unknown error occurred.""" diff --git a/homeassistant/components/sony_projector/manifest.json b/homeassistant/components/sony_projector/manifest.json index f674f6fa56b1fa..978a1b8138fa17 100644 --- a/homeassistant/components/sony_projector/manifest.json +++ b/homeassistant/components/sony_projector/manifest.json @@ -2,6 +2,7 @@ "domain": "sony_projector", "name": "Sony Projector", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sony_projector", "iot_class": "local_polling", "loggers": ["pysdcp"], diff --git a/homeassistant/components/sony_projector/strings.json b/homeassistant/components/sony_projector/strings.json new file mode 100644 index 00000000000000..60bc60bbcd1c5a --- /dev/null +++ b/homeassistant/components/sony_projector/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "Reconnect to the projector at {host}." + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/components/sony_projector/translations/en.json b/homeassistant/components/sony_projector/translations/en.json new file mode 100644 index 00000000000000..94e9b48ee3d877 --- /dev/null +++ b/homeassistant/components/sony_projector/translations/en.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "Host" + } + }, + "reauth_confirm": { + "title": "Reauthenticate Integration", + "description": "Reconnect to the projector at {host}." + } + }, + "error": { + "cannot_connect": "Failed to connect.", + "unknown": "Unexpected error." + }, + "abort": { + "already_configured": "Device is already configured", + "already_in_progress": "The flow is already in progress", + "reauth_successful": "Reauthentication was successful", + "unknown": "Unexpected error" + } + } +} diff --git a/tests/components/sony_projector/test_config_flow.py b/tests/components/sony_projector/test_config_flow.py new file mode 100644 index 00000000000000..029fd60ac33e47 --- /dev/null +++ b/tests/components/sony_projector/test_config_flow.py @@ -0,0 +1,147 @@ +"""Tests for the Sony Projector config flow.""" + +from __future__ import annotations + +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.sony_projector.config_flow import ( + DEFAULT_TITLE, + DOMAIN, +) +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +TEST_HOST = "192.0.2.1" + + +async def test_user_flow_success(hass: HomeAssistant) -> None: + """Test that the user step creates an entry when connection succeeds.""" + + with patch( + "homeassistant.components.sony_projector.config_flow.pysdcp.Projector" + ) as projector_cls: + projector_cls.return_value.get_power.return_value = True + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: TEST_HOST} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_TITLE + assert result["data"] == {CONF_HOST: TEST_HOST} + projector_cls.return_value.get_power.assert_called_once() + + +async def test_user_flow_cannot_connect(hass: HomeAssistant) -> None: + """Test that the user step surfaces connection errors.""" + + with patch( + "homeassistant.components.sony_projector.config_flow.pysdcp.Projector" + ) as projector_cls: + projector_cls.return_value.get_power.side_effect = ConnectionError + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: TEST_HOST} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_import_flow_success(hass: HomeAssistant) -> None: + """Test importing YAML configuration into a config entry.""" + + with patch( + "homeassistant.components.sony_projector.config_flow.pysdcp.Projector" + ) as projector_cls: + projector_cls.return_value.get_power.return_value = True + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_HOST: TEST_HOST, CONF_NAME: "Legacy"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_TITLE + assert result["data"] == {CONF_HOST: TEST_HOST, CONF_NAME: "Legacy"} + + +async def test_import_flow_duplicate(hass: HomeAssistant) -> None: + """Test importing YAML configuration that already exists.""" + + entry = MockConfigEntry(domain=DOMAIN, data={CONF_HOST: TEST_HOST}, unique_id=TEST_HOST) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.sony_projector.config_flow.pysdcp.Projector" + ) as projector_cls: + projector_cls.return_value.get_power.return_value = True + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_HOST: TEST_HOST}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reauth_flow_success(hass: HomeAssistant) -> None: + """Test that the reauth flow validates the connection and aborts with success.""" + + entry = MockConfigEntry(domain=DOMAIN, data={CONF_HOST: TEST_HOST}, unique_id=TEST_HOST) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.sony_projector.config_flow.pysdcp.Projector" + ) as projector_cls: + projector_cls.return_value.get_power.return_value = True + + result = await entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reauth_flow_cannot_connect(hass: HomeAssistant) -> None: + """Test that the reauth flow surfaces connection errors.""" + + entry = MockConfigEntry(domain=DOMAIN, data={CONF_HOST: TEST_HOST}, unique_id=TEST_HOST) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.sony_projector.config_flow.pysdcp.Projector" + ) as projector_cls: + projector_cls.return_value.get_power.side_effect = ConnectionError + + result = await entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} From aa898b2896c8e42586cc84a4289d441e3f1c4c31 Mon Sep 17 00:00:00 2001 From: johnmschoonover Date: Sat, 1 Nov 2025 21:14:06 -0500 Subject: [PATCH 2/3] Fix lint issues in Sony Projector config flow --- homeassistant/components/sony_projector/AGENTS.md | 1 + homeassistant/components/sony_projector/config_flow.py | 2 +- tests/components/sony_projector/__init__.py | 2 ++ tests/components/sony_projector/test_config_flow.py | 5 +---- 4 files changed, 5 insertions(+), 5 deletions(-) create mode 100644 tests/components/sony_projector/__init__.py diff --git a/homeassistant/components/sony_projector/AGENTS.md b/homeassistant/components/sony_projector/AGENTS.md index e153d16764bc74..0c1c0f2e9fbeb7 100644 --- a/homeassistant/components/sony_projector/AGENTS.md +++ b/homeassistant/components/sony_projector/AGENTS.md @@ -4,3 +4,4 @@ - Use the projector host as the config entry unique ID and prevent duplicate flows based on the host. - Do not ask users to provide a custom name during configuration; rely on the built-in default title instead. - When writing tests, patch `homeassistant.components.sony_projector.config_flow.pysdcp.Projector` to avoid real I/O. +- Address lint feedback directly instead of suppressing it; avoid adding unnecessary `noqa` directives. diff --git a/homeassistant/components/sony_projector/config_flow.py b/homeassistant/components/sony_projector/config_flow.py index 114cfafccef995..e91d6d5557db51 100644 --- a/homeassistant/components/sony_projector/config_flow.py +++ b/homeassistant/components/sony_projector/config_flow.py @@ -30,7 +30,7 @@ async def _async_validate_input(hass: HomeAssistant, data: Mapping[str, Any]) -> raise CannotConnect from err except OSError as err: raise CannotConnect from err - except Exception as err: # noqa: BLE001 + except Exception as err: raise UnknownError from err diff --git a/tests/components/sony_projector/__init__.py b/tests/components/sony_projector/__init__.py new file mode 100644 index 00000000000000..3e804c6fd0cee2 --- /dev/null +++ b/tests/components/sony_projector/__init__.py @@ -0,0 +1,2 @@ +"""Tests for the Sony Projector integration.""" + diff --git a/tests/components/sony_projector/test_config_flow.py b/tests/components/sony_projector/test_config_flow.py index 029fd60ac33e47..d8ff945156662c 100644 --- a/tests/components/sony_projector/test_config_flow.py +++ b/tests/components/sony_projector/test_config_flow.py @@ -5,10 +5,7 @@ from unittest.mock import patch from homeassistant import config_entries -from homeassistant.components.sony_projector.config_flow import ( - DEFAULT_TITLE, - DOMAIN, -) +from homeassistant.components.sony_projector.config_flow import DEFAULT_TITLE, DOMAIN from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType From 2ecd49b9af7ef2f6836fb1ca95c4a644e9283ec0 Mon Sep 17 00:00:00 2001 From: johnmschoonover Date: Sun, 2 Nov 2025 10:31:24 -0600 Subject: [PATCH 3/3] Add config entry support for Sony Projector --- .../components/sony_projector/AGENTS.md | 2 + .../components/sony_projector/__init__.py | 43 +++++ .../components/sony_projector/switch.py | 175 +++++++++++------ .../sony_projector/test_config_flow.py | 2 +- tests/components/sony_projector/test_init.py | 72 +++++++ .../components/sony_projector/test_switch.py | 180 ++++++++++++++++++ 6 files changed, 413 insertions(+), 61 deletions(-) create mode 100644 tests/components/sony_projector/test_init.py create mode 100644 tests/components/sony_projector/test_switch.py diff --git a/homeassistant/components/sony_projector/AGENTS.md b/homeassistant/components/sony_projector/AGENTS.md index 0c1c0f2e9fbeb7..cdde17c4823cbc 100644 --- a/homeassistant/components/sony_projector/AGENTS.md +++ b/homeassistant/components/sony_projector/AGENTS.md @@ -5,3 +5,5 @@ - Do not ask users to provide a custom name during configuration; rely on the built-in default title instead. - When writing tests, patch `homeassistant.components.sony_projector.config_flow.pysdcp.Projector` to avoid real I/O. - Address lint feedback directly instead of suppressing it; avoid adding unnecessary `noqa` directives. +- Enable the config entry flow only when the integration implements `async_setup_entry`/`async_unload_entry` and forwards to the switch platform so UI-configured users get entities immediately. +- The development environment pins Ruff <0.13, so `ruff format` is unavailable—prefer `ruff check --fix` and manual formatting updates when adjusting code here. diff --git a/homeassistant/components/sony_projector/__init__.py b/homeassistant/components/sony_projector/__init__.py index dfe52c7fa752da..89ed021aab8128 100644 --- a/homeassistant/components/sony_projector/__init__.py +++ b/homeassistant/components/sony_projector/__init__.py @@ -1 +1,44 @@ """The sony_projector component.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType + +DOMAIN = "sony_projector" +PLATFORMS = [Platform.SWITCH] + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Sony Projector integration.""" + + hass.data.setdefault(DOMAIN, {}) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Sony Projector from a config entry.""" + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = dict(entry.data) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a Sony Projector config entry.""" + + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id, None) + + return unload_ok + + +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate old entry formats to the latest version.""" + + return True diff --git a/homeassistant/components/sony_projector/switch.py b/homeassistant/components/sony_projector/switch.py index c4d993cc22aa69..71e70ccbb065ca 100644 --- a/homeassistant/components/sony_projector/switch.py +++ b/homeassistant/components/sony_projector/switch.py @@ -12,12 +12,15 @@ PLATFORM_SCHEMA as SWITCH_PLATFORM_SCHEMA, SwitchEntity, ) -from homeassistant.const import CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_NAME, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import DOMAIN + _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "Sony Projector" @@ -36,76 +39,128 @@ def setup_platform( add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Connect to Sony projector using network.""" + """Set up Sony Projector from YAML configuration.""" + + hass.async_create_task( + _async_setup_projector_switch( + hass, + host=config[CONF_HOST], + name=config[CONF_NAME], + async_add_entities=add_entities, + ) + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Sony Projector switches from a config entry.""" + + stored = hass.data[DOMAIN].get(entry.entry_id, entry.data) + name = stored.get(CONF_NAME, DEFAULT_NAME) + + await _async_setup_projector_switch( + hass, + host=stored[CONF_HOST], + name=name, + async_add_entities=async_add_entities, + ) + + +async def _async_setup_projector_switch( + hass: HomeAssistant, + *, + host: str, + name: str, + async_add_entities: AddEntitiesCallback, +) -> None: + """Create the projector entity after validating connectivity.""" - host = config[CONF_HOST] - name = config[CONF_NAME] - sdcp_connection = pysdcp.Projector(host) + projector = pysdcp.Projector(host) - # Sanity check the connection try: - sdcp_connection.get_power() - except ConnectionError: - _LOGGER.error("Failed to connect to projector '%s'", host) + await hass.async_add_executor_job(projector.get_power) + except (ConnectionError, OSError) as err: + _LOGGER.error("Failed to connect to projector '%s': %s", host, err) return - _LOGGER.debug("Validated projector '%s' OK", host) - add_entities([SonyProjector(sdcp_connection, name)], True) + async_add_entities( + [SonyProjectorSwitch(hass, projector=projector, name=name, host=host)], + True, + ) -class SonyProjector(SwitchEntity): + +class SonyProjectorSwitch(SwitchEntity): """Represents a Sony Projector as a switch.""" - def __init__(self, sdcp_connection, name): - """Init of the Sony projector.""" - self._sdcp = sdcp_connection - self._name = name - self._state = None - self._available = False - self._attributes = {} - - @property - def available(self) -> bool: - """Return if projector is available.""" - return self._available - - @property - def name(self): - """Return name of the projector.""" - return self._name - - @property - def is_on(self): - """Return if the projector is turned on.""" - return self._state - - @property - def extra_state_attributes(self): - """Return state attributes.""" - return self._attributes - - def update(self) -> None: - """Get the latest state from the projector.""" - try: - self._state = self._sdcp.get_power() - self._available = True - except ConnectionRefusedError: - _LOGGER.error("Projector connection refused") - self._available = False + _attr_should_poll = True + + def __init__(self, hass: HomeAssistant, *, projector, name: str, host: str) -> None: + """Initialise the Sony projector switch entity.""" - def turn_on(self, **kwargs: Any) -> None: + self._hass = hass + self._projector = projector + self._host = host + self._attr_name = name + self._attr_unique_id = host + self._attr_is_on = False + self._attr_available = False + + async def async_update(self) -> None: + """Fetch the latest state from the projector.""" + + try: + power_state = await self._hass.async_add_executor_job( + self._projector.get_power + ) + except (ConnectionError, OSError) as err: + _LOGGER.error("Failed to update projector '%s': %s", self._host, err) + self._attr_available = False + return + + self._attr_available = True + self._attr_is_on = power_state in (True, STATE_ON) + + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the projector on.""" - _LOGGER.debug("Powering on projector '%s'", self.name) - if self._sdcp.set_power(True): - _LOGGER.debug("Powered on successfully") - self._state = STATE_ON + + _LOGGER.debug("Powering on projector '%s'", self._host) + + try: + success = await self._hass.async_add_executor_job( + self._projector.set_power, True + ) + except (ConnectionError, OSError) as err: + _LOGGER.error("Failed to power on projector '%s': %s", self._host, err) + self._attr_available = False + return + + if success: + self._attr_available = True + self._attr_is_on = True + self.async_write_ha_state() else: - _LOGGER.error("Power on command was not successful") + _LOGGER.error("Power on command was not successful for '%s'", self._host) - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the projector off.""" - _LOGGER.debug("Powering off projector '%s'", self.name) - if self._sdcp.set_power(False): - _LOGGER.debug("Powered off successfully") - self._state = STATE_OFF + + _LOGGER.debug("Powering off projector '%s'", self._host) + + try: + success = await self._hass.async_add_executor_job( + self._projector.set_power, False + ) + except (ConnectionError, OSError) as err: + _LOGGER.error("Failed to power off projector '%s': %s", self._host, err) + self._attr_available = False + return + + if success: + self._attr_available = True + self._attr_is_on = False + self.async_write_ha_state() else: - _LOGGER.error("Power off command was not successful") + _LOGGER.error("Power off command was not successful for '%s'", self._host) diff --git a/tests/components/sony_projector/test_config_flow.py b/tests/components/sony_projector/test_config_flow.py index d8ff945156662c..dea21854b495b6 100644 --- a/tests/components/sony_projector/test_config_flow.py +++ b/tests/components/sony_projector/test_config_flow.py @@ -36,7 +36,7 @@ async def test_user_flow_success(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == DEFAULT_TITLE assert result["data"] == {CONF_HOST: TEST_HOST} - projector_cls.return_value.get_power.assert_called_once() + assert projector_cls.return_value.get_power.call_count >= 1 async def test_user_flow_cannot_connect(hass: HomeAssistant) -> None: diff --git a/tests/components/sony_projector/test_init.py b/tests/components/sony_projector/test_init.py new file mode 100644 index 00000000000000..ffc686df54e18a --- /dev/null +++ b/tests/components/sony_projector/test_init.py @@ -0,0 +1,72 @@ +from unittest.mock import AsyncMock, patch + +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from homeassistant.components import sony_projector +from homeassistant.components.sony_projector import DOMAIN, PLATFORMS + +from tests.common import MockConfigEntry + +TEST_HOST = "192.0.2.10" + + +async def test_async_setup_entry_stores_data_and_forwards( + hass: HomeAssistant, +) -> None: + """Ensure the config entry setup stores data and forwards to platforms.""" + + entry = MockConfigEntry(domain=DOMAIN, data={CONF_HOST: TEST_HOST}) + entry.add_to_hass(hass) + + forward_mock = AsyncMock() + + with patch.object( + hass.config_entries, + "async_forward_entry_setups", + forward_mock, + ): + assert await sony_projector.async_setup_entry(hass, entry) + + forward_mock.assert_awaited_once_with(entry, PLATFORMS) + assert hass.data[DOMAIN][entry.entry_id][CONF_HOST] == TEST_HOST + + +async def test_async_unload_entry_removes_data(hass: HomeAssistant) -> None: + """Ensure unloading a config entry cleans up stored data.""" + + entry = MockConfigEntry(domain=DOMAIN, data={CONF_HOST: TEST_HOST}) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = dict(entry.data) + + unload_mock = AsyncMock(return_value=True) + + with patch.object( + hass.config_entries, + "async_unload_platforms", + unload_mock, + ): + assert await sony_projector.async_unload_entry(hass, entry) + + unload_mock.assert_awaited_once_with(entry, PLATFORMS) + assert entry.entry_id not in hass.data[DOMAIN] + + +async def test_async_unload_entry_failure_keeps_data( + hass: HomeAssistant, +) -> None: + """Ensure stored data remains when platform unloading fails.""" + + entry = MockConfigEntry(domain=DOMAIN, data={CONF_HOST: TEST_HOST}) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = dict(entry.data) + + unload_mock = AsyncMock(return_value=False) + + with patch.object( + hass.config_entries, + "async_unload_platforms", + unload_mock, + ): + assert not await sony_projector.async_unload_entry(hass, entry) + + unload_mock.assert_awaited_once_with(entry, PLATFORMS) + assert entry.entry_id in hass.data[DOMAIN] diff --git a/tests/components/sony_projector/test_switch.py b/tests/components/sony_projector/test_switch.py new file mode 100644 index 00000000000000..5397ccef81a7f1 --- /dev/null +++ b/tests/components/sony_projector/test_switch.py @@ -0,0 +1,180 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +from homeassistant.components.sony_projector import DOMAIN, switch +from homeassistant.components.sony_projector.switch import SonyProjectorSwitch +from homeassistant.const import CONF_HOST, CONF_NAME, STATE_ON +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +TEST_HOST = "198.51.100.15" + + +async def test_async_setup_entry_adds_entity(hass: HomeAssistant) -> None: + """Ensure the switch entity is added during config entry setup.""" + + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: TEST_HOST, CONF_NAME: "Projector"} + ) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = dict(entry.data) + + projector = MagicMock() + added_entities: list[SonyProjectorSwitch] = [] + update_flags: list[bool] = [] + + def _async_add_entities(entities, update_before_add=False): + added_entities.extend(entities) + update_flags.append(update_before_add) + + executor_mock = AsyncMock(side_effect=[STATE_ON, STATE_ON]) + + with patch( + "homeassistant.components.sony_projector.switch.pysdcp.Projector", + return_value=projector, + ) as projector_cls, patch.object( + hass, "async_add_executor_job", executor_mock + ): + await switch.async_setup_entry(hass, entry, _async_add_entities) + + projector_cls.assert_called_once_with(TEST_HOST) + assert executor_mock.await_count == 1 + first_call = executor_mock.await_args_list[0] + assert first_call.args == (projector.get_power,) + assert update_flags == [True] + assert len(added_entities) == 1 + entity = added_entities[0] + assert isinstance(entity, SonyProjectorSwitch) + assert entity.unique_id == TEST_HOST + assert entity.name == "Projector" + + await entity.async_update() + + assert executor_mock.await_count == 2 + assert executor_mock.await_args_list[1].args == (projector.get_power,) + assert entity.is_on + + +async def test_async_setup_entry_connection_error(hass: HomeAssistant) -> None: + """Ensure no entities are added when the initial connection fails.""" + + entry = MockConfigEntry(domain=DOMAIN, data={CONF_HOST: TEST_HOST}) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = dict(entry.data) + + async_add_entities = MagicMock() + + with patch( + "homeassistant.components.sony_projector.switch.pysdcp.Projector", + ) as projector_cls, patch.object( + hass, + "async_add_executor_job", + AsyncMock(side_effect=ConnectionError), + ): + await switch.async_setup_entry(hass, entry, async_add_entities) + + projector_cls.assert_called_once_with(TEST_HOST) + async_add_entities.assert_not_called() + + +async def test_switch_async_update_sets_state(hass: HomeAssistant) -> None: + """Ensure the switch polls the projector using the executor.""" + + projector = MagicMock() + entity = SonyProjectorSwitch(hass, projector=projector, name="Projector", host=TEST_HOST) + + with patch.object( + hass, "async_add_executor_job", AsyncMock(return_value=STATE_ON) + ) as executor_mock: + await entity.async_update() + + executor_mock.assert_awaited_once_with(projector.get_power) + assert entity.is_on + assert entity.available + + +async def test_switch_async_update_handles_error(hass: HomeAssistant) -> None: + """Ensure availability is cleared when polling fails.""" + + projector = MagicMock() + entity = SonyProjectorSwitch(hass, projector=projector, name="Projector", host=TEST_HOST) + + with patch.object( + hass, "async_add_executor_job", AsyncMock(side_effect=ConnectionError) + ) as executor_mock: + await entity.async_update() + + executor_mock.assert_awaited_once_with(projector.get_power) + assert not entity.available + + +async def test_switch_turn_on_updates_state(hass: HomeAssistant) -> None: + """Ensure the switch uses the executor when turning on.""" + + projector = MagicMock() + entity = SonyProjectorSwitch(hass, projector=projector, name="Projector", host=TEST_HOST) + + executor_mock = AsyncMock(return_value=True) + + with patch.object(entity, "async_write_ha_state") as write_state, patch.object( + hass, "async_add_executor_job", executor_mock + ): + await entity.async_turn_on() + + executor_mock.assert_awaited_once_with(projector.set_power, True) + write_state.assert_called_once() + assert entity.is_on + assert entity.available + + +async def test_switch_turn_on_failure_marks_unavailable(hass: HomeAssistant) -> None: + """Ensure failures while turning on mark the entity unavailable.""" + + projector = MagicMock() + entity = SonyProjectorSwitch(hass, projector=projector, name="Projector", host=TEST_HOST) + + executor_mock = AsyncMock(side_effect=ConnectionError) + + with patch.object(entity, "async_write_ha_state") as write_state, patch.object( + hass, "async_add_executor_job", executor_mock + ): + await entity.async_turn_on() + + executor_mock.assert_awaited_once_with(projector.set_power, True) + write_state.assert_not_called() + assert not entity.available + + +async def test_switch_turn_off_updates_state(hass: HomeAssistant) -> None: + """Ensure the switch uses the executor when turning off.""" + + projector = MagicMock() + entity = SonyProjectorSwitch(hass, projector=projector, name="Projector", host=TEST_HOST) + + executor_mock = AsyncMock(return_value=True) + + with patch.object(entity, "async_write_ha_state") as write_state, patch.object( + hass, "async_add_executor_job", executor_mock + ): + await entity.async_turn_off() + + executor_mock.assert_awaited_once_with(projector.set_power, False) + write_state.assert_called_once() + assert not entity.is_on + assert entity.available + + +async def test_switch_turn_off_failure_marks_unavailable(hass: HomeAssistant) -> None: + """Ensure failures while turning off mark the entity unavailable.""" + + projector = MagicMock() + entity = SonyProjectorSwitch(hass, projector=projector, name="Projector", host=TEST_HOST) + + executor_mock = AsyncMock(side_effect=ConnectionError) + + with patch.object(entity, "async_write_ha_state") as write_state, patch.object( + hass, "async_add_executor_job", executor_mock + ): + await entity.async_turn_off() + + executor_mock.assert_awaited_once_with(projector.set_power, False) + write_state.assert_not_called() + assert not entity.available