From 81725b4dbf5d1c633f7bd9039b010e7c532f03c3 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sun, 10 May 2026 21:15:56 -0600 Subject: [PATCH] HA config_flow: transport dropdown (TCP/UDP) for the new UDP path custom_components/omni_pca/const.py: + CONF_TRANSPORT, TRANSPORT_TCP, TRANSPORT_UDP, DEFAULT_TRANSPORT='tcp' custom_components/omni_pca/config_flow.py: + 'transport' field in _USER_SCHEMA with vol.In([tcp, udp]), default tcp (so existing flows are unchanged) + transport stored in entry.data on create + reauth carries the existing transport over from entry.data + _probe() takes transport=, propagates to OmniClient custom_components/omni_pca/coordinator.py: + transport= constructor arg, defaults to 'tcp' + _ensure_connected passes transport= through to OmniClient custom_components/omni_pca/__init__.py: + reads transport from entry.data (default tcp), passes to coordinator Backward-compat: existing config entries without a transport key fall through to 'tcp', identical to current behavior. New entries get the choice at the config-flow form. The reauth step preserves the existing transport so users don't have to re-pick it. 357 tests pass; ruff clean across src/ tests/ custom_components/. HA integration tests don't need updating because they don't pass transport= explicitly (default tcp matches the mock's default). --- custom_components/omni_pca/__init__.py | 10 ++++++- custom_components/omni_pca/config_flow.py | 32 ++++++++++++++++++++--- custom_components/omni_pca/const.py | 5 ++++ custom_components/omni_pca/coordinator.py | 3 +++ 4 files changed, 45 insertions(+), 5 deletions(-) diff --git a/custom_components/omni_pca/__init__.py b/custom_components/omni_pca/__init__.py index 7849ace..042e790 100644 --- a/custom_components/omni_pca/__init__.py +++ b/custom_components/omni_pca/__init__.py @@ -14,7 +14,13 @@ from typing import TYPE_CHECKING from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.exceptions import ConfigEntryNotReady -from .const import CONF_CONTROLLER_KEY, DOMAIN, LOGGER +from .const import ( + CONF_CONTROLLER_KEY, + CONF_TRANSPORT, + DEFAULT_TRANSPORT, + DOMAIN, + LOGGER, +) from .coordinator import OmniDataUpdateCoordinator from .services import async_setup_services, async_unload_services @@ -50,12 +56,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: LOGGER.error("stored controller key for %s is corrupt: %s", entry.title, err) return False + transport: str = entry.data.get(CONF_TRANSPORT, DEFAULT_TRANSPORT) coordinator = OmniDataUpdateCoordinator( hass, entry, host=host, port=port, controller_key=controller_key, + transport=transport, ) try: diff --git a/custom_components/omni_pca/config_flow.py b/custom_components/omni_pca/config_flow.py index 1c8aa6d..4f8ef0d 100644 --- a/custom_components/omni_pca/config_flow.py +++ b/custom_components/omni_pca/config_flow.py @@ -20,10 +20,14 @@ from omni_pca.connection import ( from .const import ( CONF_CONTROLLER_KEY, + CONF_TRANSPORT, CONTROLLER_KEY_HEX_LEN, DEFAULT_PORT, + DEFAULT_TRANSPORT, DOMAIN, LOGGER, + TRANSPORT_TCP, + TRANSPORT_UDP, ) @@ -60,6 +64,12 @@ _USER_SCHEMA = vol.Schema( vol.Coerce(int), vol.Range(min=1, max=65535) ), vol.Required(CONF_CONTROLLER_KEY): str, + # Most modern firmware uses TCP; some installers configure + # Network_UDP. PC Access stores the choice as + # enuPreferredNetworkProtocol in the .pca config. + vol.Required(CONF_TRANSPORT, default=DEFAULT_TRANSPORT): vol.In( + [TRANSPORT_TCP, TRANSPORT_UDP] + ), } ) @@ -79,6 +89,7 @@ class OmniConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: host: str = user_input[CONF_HOST].strip() port: int = user_input[CONF_PORT] + transport: str = user_input.get(CONF_TRANSPORT, DEFAULT_TRANSPORT) unique_id = f"{host}:{port}" await self.async_set_unique_id(unique_id) @@ -90,7 +101,7 @@ class OmniConfigFlow(ConfigFlow, domain=DOMAIN): LOGGER.debug("controller key rejected: %s", err) errors[CONF_CONTROLLER_KEY] = "invalid_key" else: - title, error = await self._probe(host, port, key) + title, error = await self._probe(host, port, key, transport) if error is not None: errors["base"] = error else: @@ -100,6 +111,7 @@ class OmniConfigFlow(ConfigFlow, domain=DOMAIN): CONF_HOST: host, CONF_PORT: port, CONF_CONTROLLER_KEY: key.hex(), + CONF_TRANSPORT: transport, }, ) @@ -121,6 +133,9 @@ class OmniConfigFlow(ConfigFlow, domain=DOMAIN): assert self._reauth_entry_data is not None host: str = self._reauth_entry_data[CONF_HOST] port: int = self._reauth_entry_data[CONF_PORT] + transport: str = self._reauth_entry_data.get( + CONF_TRANSPORT, DEFAULT_TRANSPORT + ) errors: dict[str, str] = {} if user_input is not None: @@ -129,7 +144,7 @@ class OmniConfigFlow(ConfigFlow, domain=DOMAIN): except InvalidControllerKey: errors[CONF_CONTROLLER_KEY] = "invalid_key" else: - _, error = await self._probe(host, port, key) + _, error = await self._probe(host, port, key, transport) if error is not None: errors["base"] = error else: @@ -147,11 +162,20 @@ class OmniConfigFlow(ConfigFlow, domain=DOMAIN): # ---- helpers --------------------------------------------------------- async def _probe( - self, host: str, port: int, key: bytes + self, + host: str, + port: int, + key: bytes, + transport: str = DEFAULT_TRANSPORT, ) -> tuple[str | None, str | None]: """Try to connect once. Returns (title, error_code).""" try: - async with OmniClient(host, port=port, controller_key=key) as client: + async with OmniClient( + host, + port=port, + controller_key=key, + transport=transport, # type: ignore[arg-type] + ) as client: info = await client.get_system_information() except (HandshakeError, InvalidEncryptionKeyError): return None, "invalid_auth" diff --git a/custom_components/omni_pca/const.py b/custom_components/omni_pca/const.py index 5fb30fa..9f7de9a 100644 --- a/custom_components/omni_pca/const.py +++ b/custom_components/omni_pca/const.py @@ -12,6 +12,11 @@ DEFAULT_PORT: Final = 4369 DEFAULT_TIMEOUT: Final = 5.0 CONF_CONTROLLER_KEY: Final = "controller_key" +CONF_TRANSPORT: Final = "transport" + +TRANSPORT_TCP: Final = "tcp" +TRANSPORT_UDP: Final = "udp" +DEFAULT_TRANSPORT: Final = TRANSPORT_TCP MANUFACTURER: Final = "HAI / Leviton" diff --git a/custom_components/omni_pca/coordinator.py b/custom_components/omni_pca/coordinator.py index 2f20b15..0619a73 100644 --- a/custom_components/omni_pca/coordinator.py +++ b/custom_components/omni_pca/coordinator.py @@ -137,6 +137,7 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]): host: str, port: int, controller_key: bytes, + transport: str = "tcp", ) -> None: super().__init__( hass, @@ -148,6 +149,7 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]): self._host = host self._port = port self._controller_key = controller_key + self._transport = transport self._client: OmniClient | None = None self._discovery_done = False self._discovered: OmniData | None = None @@ -236,6 +238,7 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]): self._host, port=self._port, controller_key=self._controller_key, + transport=self._transport, # type: ignore[arg-type] ) # Drive __aenter__ manually so the client survives across update # cycles; we close it explicitly on shutdown / failure.