From eb523d79acd6c47cfbdfd1403f44c1d3d8e6f8d4 Mon Sep 17 00:00:00 2001 From: skynet Date: Fri, 8 May 2026 10:56:54 -0500 Subject: [PATCH 1/6] Modernize TLS handling in isy994 (replace tls_ver with verify_ssl) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the legacy TLS-version dropdown and let pyisy 3.6.0 negotiate TLS automatically with a 1.2 floor. Add a "Verify SSL" toggle (default off) that matches how eisy/Polisy/ISY-994 ship their self-signed certificates. Existing v1 config entries are migrated to v2 by silently dropping the stored "tls" key — no reauth required. Also distinguish TLS handshake failures from generic connection errors in the config flow, surfacing a dedicated "ssl_error" message via __cause__ inspection on aiohttp.ClientSSLError. Co-Authored-By: Claude Opus 4.7 --- homeassistant/components/isy994/__init__.py | 22 ++++++- .../components/isy994/config_flow.py | 32 +++++++--- homeassistant/components/isy994/const.py | 3 +- homeassistant/components/isy994/manifest.json | 2 +- homeassistant/components/isy994/strings.json | 8 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/isy994/test_config_flow.py | 11 ++-- tests/components/isy994/test_init.py | 62 +++++++++++++++++++ 9 files changed, 121 insertions(+), 23 deletions(-) create mode 100644 tests/components/isy994/test_init.py diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index ce8e6bb9274863..b3d96ab5dd8e9c 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -13,6 +13,7 @@ CONF_PASSWORD, CONF_USERNAME, CONF_VARIABLES, + CONF_VERIFY_SSL, EVENT_HOMEASSISTANT_STOP, Platform, ) @@ -31,9 +32,9 @@ CONF_IGNORE_STRING, CONF_NETWORK, CONF_SENSOR_STRING, - CONF_TLS_VER, DEFAULT_IGNORE_STRING, DEFAULT_SENSOR_STRING, + DEFAULT_VERIFY_SSL, DOMAIN, ISY_CONF_FIRMWARE, ISY_CONF_MODEL, @@ -62,6 +63,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: + """Migrate old config entries.""" + _LOGGER.debug("Migrating ISY config entry from version %s", entry.version) + + if entry.version > 2: + return False + + if entry.version == 1: + # Drop the legacy "tls" version field; pyisy now negotiates automatically. + new_data = {key: value for key, value in entry.data.items() if key != "tls"} + hass.config_entries.async_update_entry(entry, data=new_data, version=2) + + return True + + async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: """Set up the ISY 994 integration.""" isy_config = entry.data @@ -73,7 +89,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: host = urlparse(isy_config[CONF_HOST]) # Optional - tls_version = isy_config.get(CONF_TLS_VER) + verify_ssl = isy_config.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL) ignore_identifier = isy_options.get(CONF_IGNORE_STRING, DEFAULT_IGNORE_STRING) sensor_identifier = isy_options.get(CONF_SENSOR_STRING, DEFAULT_SENSOR_STRING) @@ -98,7 +114,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: username=user, password=password, use_https=https, - tls_ver=tls_version, + verify_ssl=verify_ssl, webroot=host.path, websession=session, use_websocket=True, diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py index 4306b05b832264..8f56cd9e122dd1 100644 --- a/homeassistant/components/isy994/config_flow.py +++ b/homeassistant/components/isy994/config_flow.py @@ -6,6 +6,7 @@ from typing import Any from urllib.parse import urlparse, urlunparse +import aiohttp from aiohttp import CookieJar from pyisy import ISYConnectionError, ISYInvalidAuthError, ISYResponseParseError from pyisy.configuration import Configuration @@ -18,7 +19,13 @@ ConfigFlowResult, OptionsFlowWithReload, ) -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_USERNAME, + CONF_VERIFY_SSL, +) from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError @@ -34,13 +41,12 @@ CONF_IGNORE_STRING, CONF_RESTORE_LIGHT_STATE, CONF_SENSOR_STRING, - CONF_TLS_VER, CONF_VAR_SENSOR_STRING, DEFAULT_IGNORE_STRING, DEFAULT_RESTORE_LIGHT_STATE, DEFAULT_SENSOR_STRING, - DEFAULT_TLS_VERSION, DEFAULT_VAR_SENSOR_STRING, + DEFAULT_VERIFY_SSL, DOMAIN, HTTP_PORT, HTTPS_PORT, @@ -63,7 +69,7 @@ def _data_schema(schema_input: dict[str, str]) -> vol.Schema: vol.Required(CONF_HOST, default=schema_input.get(CONF_HOST, "")): str, vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, - vol.Optional(CONF_TLS_VER, default=DEFAULT_TLS_VERSION): vol.In([1.1, 1.2]), + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool, }, extra=vol.ALLOW_EXTRA, ) @@ -77,7 +83,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, user = data[CONF_USERNAME] password = data[CONF_PASSWORD] host = urlparse(data[CONF_HOST]) - tls_version = data.get(CONF_TLS_VER) + verify_ssl = data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL) if host.scheme == SCHEME_HTTP: https = False @@ -100,7 +106,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, user, password, use_https=https, - tls_ver=tls_version, + verify_ssl=verify_ssl, webroot=host.path, websession=session, ) @@ -111,6 +117,10 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, except ISYInvalidAuthError as error: raise InvalidAuth from error except ISYConnectionError as error: + # pyisy chains the underlying aiohttp error via __cause__; ClientSSLError + # covers both protocol mismatch and certificate verification failures. + if isinstance(error.__cause__, aiohttp.ClientSSLError): + raise SslError from error raise CannotConnect from error try: @@ -130,7 +140,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, class Isy994ConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Universal Devices ISY/IoX.""" - VERSION = 1 + VERSION = 2 def __init__(self) -> None: """Initialize the ISY/IoX config flow.""" @@ -156,6 +166,8 @@ async def async_step_user( info = await validate_input(self.hass, user_input) except CannotConnect: errors["base"] = "cannot_connect" + except SslError: + errors["base"] = "ssl_error" except InvalidHost: errors["base"] = "invalid_host" except InvalidAuth: @@ -291,6 +303,8 @@ async def async_step_reauth_confirm( await validate_input(self.hass, new_data) except CannotConnect: errors["base"] = "cannot_connect" + except SslError: + errors["base"] = "ssl_error" except InvalidAuth: errors[CONF_PASSWORD] = "invalid_auth" else: @@ -368,5 +382,9 @@ class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" +class SslError(HomeAssistantError): + """Error to indicate a TLS/SSL handshake failure.""" + + class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/isy994/const.py b/homeassistant/components/isy994/const.py index 9a0acf73601857..69ca68aac71f63 100644 --- a/homeassistant/components/isy994/const.py +++ b/homeassistant/components/isy994/const.py @@ -69,13 +69,12 @@ CONF_IGNORE_STRING = "ignore_string" CONF_SENSOR_STRING = "sensor_string" CONF_VAR_SENSOR_STRING = "variable_sensor_string" -CONF_TLS_VER = "tls" CONF_RESTORE_LIGHT_STATE = "restore_light_state" DEFAULT_IGNORE_STRING = "{IGNORE ME}" DEFAULT_SENSOR_STRING = "sensor" DEFAULT_RESTORE_LIGHT_STATE = False -DEFAULT_TLS_VERSION = 1.1 +DEFAULT_VERIFY_SSL = False DEFAULT_PROGRAM_STRING = "HA." DEFAULT_VAR_SENSOR_STRING = "HA." diff --git a/homeassistant/components/isy994/manifest.json b/homeassistant/components/isy994/manifest.json index d34afac96f5ab6..81abeabfb28bef 100644 --- a/homeassistant/components/isy994/manifest.json +++ b/homeassistant/components/isy994/manifest.json @@ -24,7 +24,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyisy"], - "requirements": ["pyisy==3.5.1"], + "requirements": ["pyisy==3.6.0"], "ssdp": [ { "deviceType": "urn:udi-com:device:X_Insteon_Lighting_Device:1", diff --git a/homeassistant/components/isy994/strings.json b/homeassistant/components/isy994/strings.json index f7c8aa228386b5..440e28cea472c9 100644 --- a/homeassistant/components/isy994/strings.json +++ b/homeassistant/components/isy994/strings.json @@ -9,6 +9,7 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_host": "The host entry was not in full URL format, e.g., {sample_ip}", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "ssl_error": "TLS handshake failed. The controller may be pinned to TLS 1.1 (upgrade to ≥ 1.2 in HTTPS Server Settings) or, if 'Verify SSL' is enabled, the certificate is self-signed.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "flow_title": "{name} ({host})", @@ -25,8 +26,11 @@ "data": { "host": "[%key:common::config_flow::data::url%]", "password": "[%key:common::config_flow::data::password%]", - "tls": "The TLS version of the ISY controller.", - "username": "[%key:common::config_flow::data::username%]" + "username": "[%key:common::config_flow::data::username%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "verify_ssl": "Verify the controller's TLS certificate. Leave disabled for ISY-994/eisy/Polisy controllers using their default self-signed certificate." }, "description": "The host entry must be in full URL format, e.g., {sample_ip}", "title": "Connect to your ISY" diff --git a/requirements_all.txt b/requirements_all.txt index a5f1ed2b8dba27..ea40dcc3557f75 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2228,7 +2228,7 @@ pyiskra==0.1.27 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.5.1 +pyisy==3.6.0 # homeassistant.components.itach pyitachip2ir==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e98f39d6deae7c..7c63a3a7ec417c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1914,7 +1914,7 @@ pyiskra==0.1.27 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.5.1 +pyisy==3.6.0 # homeassistant.components.ituran pyituran==0.1.5 diff --git a/tests/components/isy994/test_config_flow.py b/tests/components/isy994/test_config_flow.py index f9cbfec90c1f7a..7d1bc5949d0c90 100644 --- a/tests/components/isy994/test_config_flow.py +++ b/tests/components/isy994/test_config_flow.py @@ -7,13 +7,12 @@ from homeassistant import config_entries from homeassistant.components.isy994.const import ( - CONF_TLS_VER, DOMAIN, ISY_URL_POSTFIX, UDN_UUID_PREFIX, ) from homeassistant.config_entries import SOURCE_DHCP, SOURCE_IGNORE, SOURCE_SSDP -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo @@ -30,7 +29,7 @@ MOCK_PASSWORD = "test-password" # Don't use the integration defaults here to make sure they're being set correctly. -MOCK_TLS_VERSION = 1.2 +MOCK_VERIFY_SSL = True MOCK_IGNORE_STRING = "{IGNOREME}" MOCK_RESTORE_LIGHT_STATE = True MOCK_SENSOR_STRING = "IMASENSOR" @@ -40,13 +39,13 @@ CONF_HOST: f"http://{MOCK_HOSTNAME}", CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD, - CONF_TLS_VER: MOCK_TLS_VERSION, + CONF_VERIFY_SSL: MOCK_VERIFY_SSL, } MOCK_IOX_USER_INPUT = { CONF_HOST: f"http://{MOCK_HOSTNAME}:8080", CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD, - CONF_TLS_VER: MOCK_TLS_VERSION, + CONF_VERIFY_SSL: MOCK_VERIFY_SSL, } MOCK_DEVICE_NAME = "Name of the device" @@ -127,7 +126,7 @@ async def test_form_invalid_host(hass: HomeAssistant) -> None: "host": MOCK_HOSTNAME, # Test with missing protocol (http://) "username": MOCK_USERNAME, "password": MOCK_PASSWORD, - "tls": MOCK_TLS_VERSION, + "verify_ssl": MOCK_VERIFY_SSL, }, ) diff --git a/tests/components/isy994/test_init.py b/tests/components/isy994/test_init.py new file mode 100644 index 00000000000000..71c6ea4cba51b5 --- /dev/null +++ b/tests/components/isy994/test_init.py @@ -0,0 +1,62 @@ +"""Test the Universal Devices ISY/IoX integration init.""" + +from unittest.mock import MagicMock + +from homeassistant.components.isy994.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +MOCK_UUID = "ce:fb:72:31:b7:b9" + + +async def test_migrate_v1_drops_tls( + hass: HomeAssistant, + mock_isy: MagicMock, +) -> None: + """Test that the v1 → v2 migration silently drops the legacy "tls" key.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=1, + data={ + CONF_HOST: "http://1.1.1.1", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", + "tls": 1.1, + }, + unique_id=MOCK_UUID, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert entry.version == 2 + assert "tls" not in entry.data + assert CONF_VERIFY_SSL not in entry.data + + +async def test_migrate_future_version_fails( + hass: HomeAssistant, + mock_isy: MagicMock, +) -> None: + """Test that migrating from a future version is not possible.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=3, + data={ + CONF_HOST: "http://1.1.1.1", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", + }, + unique_id=MOCK_UUID, + ) + entry.add_to_hass(hass) + + assert not await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.MIGRATION_ERROR From 598cbc959c6af4c65ae1664b4df9c69851183175 Mon Sep 17 00:00:00 2001 From: skynet Date: Fri, 8 May 2026 11:33:28 -0500 Subject: [PATCH 2/6] isy994: bump pyisy to 3.6.1 and address review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump pyisy 3.6.0 → 3.6.1 (drops the stray top-level tests/ dir from the published wheel that hassfest rejected). - Pass verify_ssl into the aiohttp ClientSession for HTTPS in both the config flow and async_setup_entry, so the user's "Verify SSL" choice takes effect at the session level instead of relying solely on pyisy's per-request SSL context. - Add a config flow test covering the new ssl_error mapping path (ISYConnectionError chained from aiohttp.ClientSSLError). Co-Authored-By: Claude Opus 4.7 --- homeassistant/components/isy994/__init__.py | 2 +- .../components/isy994/config_flow.py | 2 +- homeassistant/components/isy994/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/isy994/test_config_flow.py | 23 +++++++++++++++++++ 6 files changed, 28 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index b3d96ab5dd8e9c..c475dfc7148afa 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -102,7 +102,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: elif host.scheme == SCHEME_HTTPS: https = True port = host.port or 443 - session = aiohttp_client.async_get_clientsession(hass) + session = aiohttp_client.async_get_clientsession(hass, verify_ssl=verify_ssl) else: _LOGGER.error("The ISY/IoX host value in configuration is invalid") return False diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py index 8f56cd9e122dd1..533fde136bacb4 100644 --- a/homeassistant/components/isy994/config_flow.py +++ b/homeassistant/components/isy994/config_flow.py @@ -94,7 +94,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, elif host.scheme == SCHEME_HTTPS: https = True port = host.port or HTTPS_PORT - session = aiohttp_client.async_get_clientsession(hass) + session = aiohttp_client.async_get_clientsession(hass, verify_ssl=verify_ssl) else: _LOGGER.error("The ISY/IoX host value in configuration is invalid") raise InvalidHost diff --git a/homeassistant/components/isy994/manifest.json b/homeassistant/components/isy994/manifest.json index 81abeabfb28bef..02aa667602ae4d 100644 --- a/homeassistant/components/isy994/manifest.json +++ b/homeassistant/components/isy994/manifest.json @@ -24,7 +24,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyisy"], - "requirements": ["pyisy==3.6.0"], + "requirements": ["pyisy==3.6.1"], "ssdp": [ { "deviceType": "urn:udi-com:device:X_Insteon_Lighting_Device:1", diff --git a/requirements_all.txt b/requirements_all.txt index ea40dcc3557f75..34690acf12dd9c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2228,7 +2228,7 @@ pyiskra==0.1.27 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.6.0 +pyisy==3.6.1 # homeassistant.components.itach pyitachip2ir==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7c63a3a7ec417c..eaa051aea27c8f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1914,7 +1914,7 @@ pyiskra==0.1.27 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.6.0 +pyisy==3.6.1 # homeassistant.components.ituran pyituran==0.1.5 diff --git a/tests/components/isy994/test_config_flow.py b/tests/components/isy994/test_config_flow.py index 7d1bc5949d0c90..22ebd422cd9c59 100644 --- a/tests/components/isy994/test_config_flow.py +++ b/tests/components/isy994/test_config_flow.py @@ -1,8 +1,10 @@ """Test the Universal Devices ISY/IoX config flow.""" import re +import ssl from unittest.mock import patch +import aiohttp from pyisy import ISYConnectionError, ISYInvalidAuthError from homeassistant import config_entries @@ -188,6 +190,27 @@ async def test_form_isy_connection_error(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "cannot_connect"} +async def test_form_isy_ssl_error(hass: HomeAssistant) -> None: + """Test we surface ssl_error when pyisy chains an aiohttp.ClientSSLError.""" + ssl_cause = aiohttp.ClientSSLError( + connection_key=None, os_error=ssl.SSLError("handshake failed") + ) + isy_error = ISYConnectionError("ssl handshake failed") + isy_error.__cause__ = ssl_cause + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch(PATCH_CONNECTION, side_effect=isy_error): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_INPUT, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "ssl_error"} + + async def test_form_isy_parse_response_error(hass: HomeAssistant) -> None: """Test we handle poorly formatted XML response from ISY.""" result = await hass.config_entries.flow.async_init( From 06f4a469d3b9256c0d57d085622555aaf5ddbeea Mon Sep 17 00:00:00 2001 From: skynet Date: Fri, 8 May 2026 12:05:39 -0500 Subject: [PATCH 3/6] isy994: add coverage for SSL error in reauth and HTTPS flow path Codecov flagged the new ssl_error catch in async_step_reauth_confirm and the verify_ssl-aware HTTPS session branch in validate_input as uncovered: - Extend test_reauth to chain a ClientSSLError-cause through the reauth flow before the success path, exercising the new except SslError branch. - Switch test_form_isy_ssl_error to use an HTTPS URL so the async_get_clientsession(..., verify_ssl=...) line in the HTTPS branch is also covered. Brings config_flow.py patch coverage to 100%; only remaining miss is a pre-existing line in OptionsFlowHandler unrelated to this PR. Co-Authored-By: Claude Opus 4.7 --- tests/components/isy994/test_config_flow.py | 26 ++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/tests/components/isy994/test_config_flow.py b/tests/components/isy994/test_config_flow.py index 22ebd422cd9c59..b1d4fa13c491b1 100644 --- a/tests/components/isy994/test_config_flow.py +++ b/tests/components/isy994/test_config_flow.py @@ -191,7 +191,11 @@ async def test_form_isy_connection_error(hass: HomeAssistant) -> None: async def test_form_isy_ssl_error(hass: HomeAssistant) -> None: - """Test we surface ssl_error when pyisy chains an aiohttp.ClientSSLError.""" + """Test we surface ssl_error when pyisy chains an aiohttp.ClientSSLError. + + Uses an HTTPS URL so the HTTPS session branch (which honors verify_ssl) + is also exercised. + """ ssl_cause = aiohttp.ClientSSLError( connection_key=None, os_error=ssl.SSLError("handshake failed") ) @@ -204,7 +208,7 @@ async def test_form_isy_ssl_error(hass: HomeAssistant) -> None: with patch(PATCH_CONNECTION, side_effect=isy_error): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - MOCK_USER_INPUT, + {**MOCK_USER_INPUT, CONF_HOST: f"https://{MOCK_HOSTNAME}"}, ) assert result2["type"] is FlowResultType.FORM @@ -703,6 +707,22 @@ async def test_reauth(hass: HomeAssistant) -> None: assert result3["type"] is FlowResultType.FORM assert result3["errors"] == {"base": "cannot_connect"} + ssl_error = ISYConnectionError("ssl handshake failed") + ssl_error.__cause__ = aiohttp.ClientSSLError( + connection_key=None, os_error=ssl.SSLError("handshake failed") + ) + with patch(PATCH_CONNECTION, side_effect=ssl_error): + result_ssl = await hass.config_entries.flow.async_configure( + result3["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result_ssl["type"] is FlowResultType.FORM + assert result_ssl["errors"] == {"base": "ssl_error"} + with ( patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch( @@ -711,7 +731,7 @@ async def test_reauth(hass: HomeAssistant) -> None: ) as mock_setup_entry, ): result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], + result_ssl["flow_id"], { CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password", From b41568b6adeaae28ddbac83e843d824c304ae392 Mon Sep 17 00:00:00 2001 From: skynet Date: Wed, 13 May 2026 14:38:28 -0500 Subject: [PATCH 4/6] isy994: switch to minor-version migration and tidy verify_ssl plumbing Address review feedback on #170136: instead of bumping the major config entry version (which would have broken rollback), use MINOR_VERSION = 2 with an async_migrate_entry that drops the legacy "tls" key and seeds verify_ssl. Mirrors the pattern in yale_smart_alarm. Also use direct key indexing for verify_ssl now that migration guarantees the key is present, switch test_config_flow string literals to the matching CONF_* constants, and drop the unused mock_isy fixture arg in the new test_init. Co-Authored-By: Claude Opus 4.7 --- homeassistant/components/isy994/__init__.py | 15 +++----- .../components/isy994/config_flow.py | 5 +-- tests/components/isy994/test_config_flow.py | 9 ++--- tests/components/isy994/test_init.py | 36 ++++--------------- 4 files changed, 18 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index c475dfc7148afa..926f805a2566f7 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -65,15 +65,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_migrate_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: """Migrate old config entries.""" - _LOGGER.debug("Migrating ISY config entry from version %s", entry.version) - - if entry.version > 2: - return False - - if entry.version == 1: - # Drop the legacy "tls" version field; pyisy now negotiates automatically. + if entry.version == 1 and entry.minor_version == 1: new_data = {key: value for key, value in entry.data.items() if key != "tls"} - hass.config_entries.async_update_entry(entry, data=new_data, version=2) + new_data.setdefault(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL) + hass.config_entries.async_update_entry(entry, data=new_data, minor_version=2) return True @@ -87,9 +82,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: user = isy_config[CONF_USERNAME] password = isy_config[CONF_PASSWORD] host = urlparse(isy_config[CONF_HOST]) - - # Optional - verify_ssl = isy_config.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL) + verify_ssl = isy_config[CONF_VERIFY_SSL] ignore_identifier = isy_options.get(CONF_IGNORE_STRING, DEFAULT_IGNORE_STRING) sensor_identifier = isy_options.get(CONF_SENSOR_STRING, DEFAULT_SENSOR_STRING) diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py index 533fde136bacb4..976c4c76ef60a8 100644 --- a/homeassistant/components/isy994/config_flow.py +++ b/homeassistant/components/isy994/config_flow.py @@ -83,7 +83,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, user = data[CONF_USERNAME] password = data[CONF_PASSWORD] host = urlparse(data[CONF_HOST]) - verify_ssl = data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL) + verify_ssl = data[CONF_VERIFY_SSL] if host.scheme == SCHEME_HTTP: https = False @@ -140,7 +140,8 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, class Isy994ConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Universal Devices ISY/IoX.""" - VERSION = 2 + VERSION = 1 + MINOR_VERSION = 2 def __init__(self) -> None: """Initialize the ISY/IoX config flow.""" diff --git a/tests/components/isy994/test_config_flow.py b/tests/components/isy994/test_config_flow.py index b1d4fa13c491b1..c9cdadbcd2838e 100644 --- a/tests/components/isy994/test_config_flow.py +++ b/tests/components/isy994/test_config_flow.py @@ -125,10 +125,10 @@ async def test_form_invalid_host(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "host": MOCK_HOSTNAME, # Test with missing protocol (http://) - "username": MOCK_USERNAME, - "password": MOCK_PASSWORD, - "verify_ssl": MOCK_VERIFY_SSL, + CONF_HOST: MOCK_HOSTNAME, # Test with missing protocol (http://) + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + CONF_VERIFY_SSL: MOCK_VERIFY_SSL, }, ) @@ -667,6 +667,7 @@ async def test_reauth(hass: HomeAssistant) -> None: data={ CONF_USERNAME: "bob", CONF_HOST: f"http://{MOCK_HOSTNAME}:1443{ISY_URL_POSTFIX}", + CONF_VERIFY_SSL: False, }, unique_id=MOCK_UUID, ) diff --git a/tests/components/isy994/test_init.py b/tests/components/isy994/test_init.py index 71c6ea4cba51b5..01a6a2ac46edb5 100644 --- a/tests/components/isy994/test_init.py +++ b/tests/components/isy994/test_init.py @@ -1,7 +1,5 @@ """Test the Universal Devices ISY/IoX integration init.""" -from unittest.mock import MagicMock - from homeassistant.components.isy994.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL @@ -12,14 +10,14 @@ MOCK_UUID = "ce:fb:72:31:b7:b9" -async def test_migrate_v1_drops_tls( +async def test_migrate_minor_version_drops_tls( hass: HomeAssistant, - mock_isy: MagicMock, ) -> None: - """Test that the v1 → v2 migration silently drops the legacy "tls" key.""" + """Test minor migration drops legacy "tls" and seeds verify_ssl.""" entry = MockConfigEntry( domain=DOMAIN, version=1, + minor_version=1, data={ CONF_HOST: "http://1.1.1.1", CONF_USERNAME: "user", @@ -34,29 +32,7 @@ async def test_migrate_v1_drops_tls( await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED - assert entry.version == 2 + assert entry.version == 1 + assert entry.minor_version == 2 assert "tls" not in entry.data - assert CONF_VERIFY_SSL not in entry.data - - -async def test_migrate_future_version_fails( - hass: HomeAssistant, - mock_isy: MagicMock, -) -> None: - """Test that migrating from a future version is not possible.""" - entry = MockConfigEntry( - domain=DOMAIN, - version=3, - data={ - CONF_HOST: "http://1.1.1.1", - CONF_USERNAME: "user", - CONF_PASSWORD: "pass", - }, - unique_id=MOCK_UUID, - ) - entry.add_to_hass(hass) - - assert not await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert entry.state is ConfigEntryState.MIGRATION_ERROR + assert entry.data[CONF_VERIFY_SSL] is False From c6dfe8fe61e0c3af845f3f0bf4119c85f2e7a942 Mon Sep 17 00:00:00 2001 From: skynet Date: Wed, 20 May 2026 09:43:55 -0500 Subject: [PATCH 5/6] Cover verify_ssl plumbing into pyisy in tests Add parametrized assertions that the verify_ssl entry option is forwarded to pyisy's ISY constructor during async_setup_entry and to its Connection during config flow validation, so a future regression that drops the plumbing cannot pass tests. Co-Authored-By: Claude Opus 4.7 --- tests/components/isy994/test_config_flow.py | 38 ++++++++++++++++++++- tests/components/isy994/test_init.py | 35 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/tests/components/isy994/test_config_flow.py b/tests/components/isy994/test_config_flow.py index c9cdadbcd2838e..ae226c423e927c 100644 --- a/tests/components/isy994/test_config_flow.py +++ b/tests/components/isy994/test_config_flow.py @@ -2,10 +2,11 @@ import re import ssl -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import aiohttp from pyisy import ISYConnectionError, ISYInvalidAuthError +import pytest from homeassistant import config_entries from homeassistant.components.isy994.const import ( @@ -215,6 +216,41 @@ async def test_form_isy_ssl_error(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "ssl_error"} +@pytest.mark.parametrize("verify_ssl", [True, False]) +async def test_form_forwards_verify_ssl_to_connection( + hass: HomeAssistant, verify_ssl: bool +) -> None: + """Test verify_ssl is forwarded to the pyisy Connection used during validation.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with ( + patch( + f"{INTEGRATION}.config_flow.Connection", + ) as mock_connection_cls, + patch( + PATCH_ASYNC_SETUP_ENTRY, + return_value=True, + ), + ): + mock_connection_cls.return_value.test_connection = AsyncMock( + return_value=MOCK_CONFIG_RESPONSE + ) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + **MOCK_USER_INPUT, + CONF_HOST: f"https://{MOCK_HOSTNAME}", + CONF_VERIFY_SSL: verify_ssl, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert mock_connection_cls.call_args.kwargs["verify_ssl"] is verify_ssl + + async def test_form_isy_parse_response_error(hass: HomeAssistant) -> None: """Test we handle poorly formatted XML response from ISY.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/isy994/test_init.py b/tests/components/isy994/test_init.py index 01a6a2ac46edb5..2cd003ce5d835b 100644 --- a/tests/components/isy994/test_init.py +++ b/tests/components/isy994/test_init.py @@ -1,5 +1,9 @@ """Test the Universal Devices ISY/IoX integration init.""" +from unittest.mock import MagicMock, patch + +import pytest + from homeassistant.components.isy994.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL @@ -36,3 +40,34 @@ async def test_migrate_minor_version_drops_tls( assert entry.minor_version == 2 assert "tls" not in entry.data assert entry.data[CONF_VERIFY_SSL] is False + + +@pytest.mark.parametrize("verify_ssl", [True, False]) +async def test_setup_forwards_verify_ssl_to_pyisy( + hass: HomeAssistant, + mock_isy: MagicMock, + verify_ssl: bool, +) -> None: + """Test the verify_ssl entry option is forwarded to the pyisy ISY constructor.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=1, + minor_version=2, + data={ + CONF_HOST: "https://1.1.1.1", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", + CONF_VERIFY_SSL: verify_ssl, + }, + unique_id=MOCK_UUID, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.isy994.ISY", return_value=mock_isy + ) as isy_constructor: + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert isy_constructor.call_args.kwargs["verify_ssl"] is verify_ssl From 85c3a64226f2e1fb899174e958e55c67eef7f34a Mon Sep 17 00:00:00 2001 From: shbatm Date: Wed, 20 May 2026 10:35:43 -0500 Subject: [PATCH 6/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/isy994/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/isy994/strings.json b/homeassistant/components/isy994/strings.json index 440e28cea472c9..ef516bfb64f8ee 100644 --- a/homeassistant/components/isy994/strings.json +++ b/homeassistant/components/isy994/strings.json @@ -9,7 +9,7 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_host": "The host entry was not in full URL format, e.g., {sample_ip}", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "ssl_error": "TLS handshake failed. The controller may be pinned to TLS 1.1 (upgrade to ≥ 1.2 in HTTPS Server Settings) or, if 'Verify SSL' is enabled, the certificate is self-signed.", + "ssl_error": "TLS handshake failed. The controller may require a newer TLS version, or SSL verification may be failing due to a self-signed certificate.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "flow_title": "{name} ({host})",