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).
This commit is contained in:
Ryan Malloy 2026-05-10 21:15:56 -06:00
parent 7f82dbbbfa
commit 81725b4dbf
4 changed files with 45 additions and 5 deletions

View File

@ -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:

View File

@ -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"

View File

@ -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"

View File

@ -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.