Bump pyisy to 3.6.1 and modernize TLS handling for isy994 (#170136)

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
shbatm
2026-05-20 18:12:11 +02:00
committed by GitHub
co-authored by Claude Opus 4.7 Joost Lekkerkerker Copilot Autofix powered by AI
parent 8fd3dcc7b1
commit 6159516dc0
9 changed files with 214 additions and 31 deletions
+15 -6
View File
@@ -13,6 +13,7 @@ from homeassistant.const import (
CONF_PASSWORD,
CONF_USERNAME,
CONF_VARIABLES,
CONF_VERIFY_SSL,
EVENT_HOMEASSISTANT_STOP,
Platform,
)
@@ -31,9 +32,9 @@ from .const import (
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,16 @@ 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."""
if entry.version == 1 and entry.minor_version == 1:
new_data = {key: value for key, value in entry.data.items() if key != "tls"}
new_data.setdefault(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL)
hass.config_entries.async_update_entry(entry, data=new_data, minor_version=2)
return True
async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool:
"""Set up the ISY 994 integration."""
isy_config = entry.data
@@ -71,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
tls_version = isy_config.get(CONF_TLS_VER)
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)
@@ -86,7 +95,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
@@ -98,7 +107,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,
+26 -7
View File
@@ -6,6 +6,7 @@ import logging
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 @@ from homeassistant.config_entries import (
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 @@ from .const import (
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[CONF_VERIFY_SSL]
if host.scheme == SCHEME_HTTP:
https = False
@@ -88,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
@@ -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:
@@ -131,6 +141,7 @@ class Isy994ConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Universal Devices ISY/IoX."""
VERSION = 1
MINOR_VERSION = 2
def __init__(self) -> None:
"""Initialize the ISY/IoX config flow."""
@@ -156,6 +167,8 @@ class Isy994ConfigFlow(ConfigFlow, domain=DOMAIN):
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 +304,8 @@ class Isy994ConfigFlow(ConfigFlow, domain=DOMAIN):
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 +383,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."""
+1 -2
View File
@@ -69,13 +69,12 @@ CONF_NETWORK = "network"
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."
@@ -24,7 +24,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["pyisy"],
"requirements": ["pyisy==3.5.1"],
"requirements": ["pyisy==3.6.1"],
"ssdp": [
{
"deviceType": "urn:udi-com:device:X_Insteon_Lighting_Device:1",
+6 -2
View File
@@ -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 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})",
@@ -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"
+1 -1
View File
@@ -2240,7 +2240,7 @@ pyiskra==0.1.27
pyiss==1.0.1
# homeassistant.components.isy994
pyisy==3.5.1
pyisy==3.6.1
# homeassistant.components.itach
pyitachip2ir==0.0.7
+1 -1
View File
@@ -1929,7 +1929,7 @@ pyiskra==0.1.27
pyiss==1.0.1
# homeassistant.components.isy994
pyisy==3.5.1
pyisy==3.6.1
# homeassistant.components.ituran
pyituran==0.1.5
+90 -11
View File
@@ -1,19 +1,21 @@
"""Test the Universal Devices ISY/IoX config flow."""
import re
from unittest.mock import patch
import ssl
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 (
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 +32,7 @@ MOCK_USERNAME = "test-username"
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 +42,13 @@ MOCK_USER_INPUT = {
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"
@@ -124,10 +126,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,
"tls": MOCK_TLS_VERSION,
CONF_HOST: MOCK_HOSTNAME, # Test with missing protocol (http://)
CONF_USERNAME: MOCK_USERNAME,
CONF_PASSWORD: MOCK_PASSWORD,
CONF_VERIFY_SSL: MOCK_VERIFY_SSL,
},
)
@@ -189,6 +191,66 @@ 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.
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")
)
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, CONF_HOST: f"https://{MOCK_HOSTNAME}"},
)
assert result2["type"] is FlowResultType.FORM
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(
@@ -641,6 +703,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,
)
@@ -681,6 +744,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(
@@ -689,7 +768,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",
+73
View File
@@ -0,0 +1,73 @@
"""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
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
MOCK_UUID = "ce:fb:72:31:b7:b9"
async def test_migrate_minor_version_drops_tls(
hass: HomeAssistant,
) -> None:
"""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",
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 == 1
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