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.