From aad284a9ba750d1793ffbfc1834a86a5e41000ab Mon Sep 17 00:00:00 2001 From: orandasoft Date: Tue, 2 Jun 2026 07:23:09 +0000 Subject: [PATCH] =?UTF-8?q?Add=20Global=20Cach=C3=A9=20iTach=20IP2IR=20inf?= =?UTF-8?q?rared=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CODEOWNERS | 2 + .../components/itachip2ir/ARCHITECTURE.md | 415 +++++ homeassistant/components/itachip2ir/README.md | 210 +++ .../components/itachip2ir/__init__.py | 210 +++ .../components/itachip2ir/config_flow.py | 476 ++++++ homeassistant/components/itachip2ir/const.py | 8 + .../components/itachip2ir/diagnostics.py | 94 ++ .../components/itachip2ir/discovery.py | 317 ++++ .../components/itachip2ir/icons.json | 12 + .../components/itachip2ir/infrared.py | 180 +++ .../components/itachip2ir/manifest.json | 18 + .../components/itachip2ir/options_flow.py | 100 ++ homeassistant/components/itachip2ir/py.typed | 0 .../components/itachip2ir/quality_scale.yaml | 112 ++ .../components/itachip2ir/repairs.py | 100 ++ .../components/itachip2ir/strings.json | 117 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/dhcp.py | 4 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + tests/components/itachip2ir/__init__.py | 1 + tests/components/itachip2ir/conftest.py | 48 + .../components/itachip2ir/test_config_flow.py | 1377 +++++++++++++++++ .../components/itachip2ir/test_diagnostics.py | 266 ++++ tests/components/itachip2ir/test_discovery.py | 656 ++++++++ tests/components/itachip2ir/test_import.py | 9 + tests/components/itachip2ir/test_infrared.py | 261 ++++ tests/components/itachip2ir/test_init.py | 507 ++++++ .../itachip2ir/test_options_flow.py | 261 ++++ tests/components/itachip2ir/test_repairs.py | 174 +++ 30 files changed, 5945 insertions(+) create mode 100644 homeassistant/components/itachip2ir/ARCHITECTURE.md create mode 100644 homeassistant/components/itachip2ir/README.md create mode 100644 homeassistant/components/itachip2ir/__init__.py create mode 100644 homeassistant/components/itachip2ir/config_flow.py create mode 100644 homeassistant/components/itachip2ir/const.py create mode 100644 homeassistant/components/itachip2ir/diagnostics.py create mode 100644 homeassistant/components/itachip2ir/discovery.py create mode 100644 homeassistant/components/itachip2ir/icons.json create mode 100644 homeassistant/components/itachip2ir/infrared.py create mode 100644 homeassistant/components/itachip2ir/manifest.json create mode 100644 homeassistant/components/itachip2ir/options_flow.py create mode 100644 homeassistant/components/itachip2ir/py.typed create mode 100644 homeassistant/components/itachip2ir/quality_scale.yaml create mode 100644 homeassistant/components/itachip2ir/repairs.py create mode 100644 homeassistant/components/itachip2ir/strings.json create mode 100644 tests/components/itachip2ir/__init__.py create mode 100644 tests/components/itachip2ir/conftest.py create mode 100644 tests/components/itachip2ir/test_config_flow.py create mode 100644 tests/components/itachip2ir/test_diagnostics.py create mode 100644 tests/components/itachip2ir/test_discovery.py create mode 100644 tests/components/itachip2ir/test_import.py create mode 100644 tests/components/itachip2ir/test_infrared.py create mode 100644 tests/components/itachip2ir/test_init.py create mode 100644 tests/components/itachip2ir/test_options_flow.py create mode 100644 tests/components/itachip2ir/test_repairs.py diff --git a/CODEOWNERS b/CODEOWNERS index 0802144fb666ee..532ec2b6be55d4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -911,6 +911,8 @@ CLAUDE.md @home-assistant/core /tests/components/ista_ecotrend/ @tr4nt0r /homeassistant/components/isy994/ @bdraco @shbatm /tests/components/isy994/ @bdraco @shbatm +/homeassistant/components/itachip2ir/ @orandasoft +/tests/components/itachip2ir/ @orandasoft /homeassistant/components/ituran/ @shmuelzon /tests/components/ituran/ @shmuelzon /homeassistant/components/izone/ @Swamp-Ig diff --git a/homeassistant/components/itachip2ir/ARCHITECTURE.md b/homeassistant/components/itachip2ir/ARCHITECTURE.md new file mode 100644 index 00000000000000..1613f55225f668 --- /dev/null +++ b/homeassistant/components/itachip2ir/ARCHITECTURE.md @@ -0,0 +1,415 @@ +# iTach IP2IR Integration Architecture + +This document describes the structure of the iTach IP2IR Home Assistant integration and how its runtime components interact. + +The integration provides Home Assistant `infrared` entities for physical infrared-capable output ports on Global Caché iTach IP2IR devices. + +The integration communicates directly with the device over the local network and does not depend on any cloud service. + +--- + +# 1. High-Level Overview + +At a high level, the runtime architecture is: + +```text +Home Assistant + ↓ +Config Flow / Discovery / Options Flow + ↓ +ConfigEntry + ↓ +Runtime Data + ↓ +Infrared Entities + ↓ +pyitach Client + ↓ +Global Caché iTach IP2IR +``` + +The integration focuses on low-level infrared transmission through the Home Assistant `infrared` domain. + +Related Home Assistant developer documentation: + +- Infrared building block: + https://developers.home-assistant.io/docs/core/entity/infrared/ + +--- + +# 2. iTach Hardware and Protocol Summary + +The Global Caché iTach IP2IR is a local-network infrared controller. + +The device exposes a TCP command API, normally on port `4998`, and transmits infrared commands through numbered physical connector ports. + +The integration uses the protocol to: + +- Query device information +- Query infrared connector modes +- Detect infrared-capable ports +- Transmit infrared commands + +The protocol is sequential. Commands are sent over a TCP connection and responses are received through the same connection. + +Infrared transmission completion is reported through `completeir` responses. + +Low-level protocol handling is implemented by the external `pyitach` library. + +--- + +# 3. Design Goals + +The integration architecture prioritizes: + +- Strict separation between Home Assistant runtime behavior and low-level protocol handling +- Concurrency safety for the sequential iTach TCP protocol +- Resilience against DHCP and host/IP changes +- Clean unload and reload handling +- Strong automated testability + +--- + +# 4. Architectural Separation + +The integration separates: + +- Home Assistant entity logic +- Runtime orchestration +- Discovery handling +- Protocol communication + +The Home Assistant integration layer is responsible for: + +- ConfigEntry lifecycle management +- Entities +- Diagnostics +- Repairs +- Translations +- User-facing errors + +The `pyitach` library is responsible for: + +- TCP communication +- Protocol parsing +- Discovery beacon parsing +- Capability detection +- Device identifier normalization + +--- + +# 5. Discovery Architecture + +Discovery is implemented in `discovery.py`. + +The integration supports: + +- Home Assistant DHCP discovery +- Global Caché UDP beacon discovery + +DHCP discovery is handled by Home Assistant and enters the integration through the config flow. + +UDP discovery runs as a shared listener started during integration setup. + +Discovery responsibilities include: + +- Listening for discovery beacons +- Parsing beacon payloads +- Normalizing discovered hosts +- Normalizing device identifiers +- Filtering unsupported devices +- Suppressing duplicate discovery flows +- Tracking host/IP changes + +Discovery is managed outside entity lifecycle state so device tracking continues independently from individual entities. + +UDP discovery supplements configuration but is not required for runtime operation after setup completes. + +--- + +# 6. Device Identity + +The integration avoids using IP addresses as stable identifiers. + +The preferred stable identity is the Global Caché device identifier derived from the device MAC address. + +The device identifier is used as the ConfigEntry unique ID. + +This allows the integration to distinguish: + +- The same device at a new IP address +- A different device at the same IP address +- Duplicate setup attempts + +When discovery detects a known device at a new host address, the integration updates the stored host and reloads the ConfigEntry automatically. + +--- + +# 7. Config Flow + +`config_flow.py` handles setup, discovery confirmation, and reconfiguration. + +Manual setup responsibilities: + +- Accept host and port +- Validate the target device +- Query infrared capability +- Normalize the device identifier +- Create the ConfigEntry + +Discovery flow responsibilities: + +- Receive discovery information +- Prevent duplicate entries +- Ask the user to confirm setup +- Create the ConfigEntry + +Reconfiguration responsibilities: + +- Allow host and port updates +- Validate the new target before saving + +The config flow validates the device before entity creation to avoid partially configured runtime state. + +--- + +# 8. Runtime Setup + +`__init__.py` manages integration setup and unload handling. + +Responsibilities include: + +- Starting shared UDP discovery +- Creating one `ItachClient` per ConfigEntry +- Creating runtime data +- Forwarding platform setup +- Handling unload cleanup +- Closing runtime clients + +Runtime-only objects are stored in: + +```python +entry.runtime_data +``` + +Shared integration state is stored in: + +```python +hass.data[DOMAIN] +``` + +Persistent configuration is stored in: + +```python +ConfigEntry.data +ConfigEntry.options +``` + +The integration does not persist transient runtime state such as: + +- TCP sockets +- Active tasks +- Runtime availability state + +--- + +# 9. Connection Lifecycle + +The integration creates one `ItachClient` per ConfigEntry and reuses the TCP connection across commands. + +The client reconnects automatically if the connection becomes stale or unavailable. + +Connectivity is verified during: + +- Setup +- Reconfiguration +- Capability refresh +- Command execution + +The integration is fully asyncio-based and does not use dedicated transport worker threads. + +--- + +# 10. Infrared Platform + +`infrared.py` exposes physical infrared-capable output ports as Home Assistant infrared entities. + +Each infrared-capable output port becomes one infrared entity. + +Entity responsibilities include: + +- Representing one physical infrared output port +- Exposing device information +- Transmitting infrared timing data +- Converting normalized timing data into iTach protocol commands +- Surfacing connection and command errors +- Reporting availability + +The infrared entity is the only entity type that directly communicates with the runtime `ItachClient`. + +Related Home Assistant developer documentation: + +https://developers.home-assistant.io/docs/core/entity/infrared/ + +--- + +# 11. Infrared Payload Handling + +The integration transmits normalized Home Assistant infrared timing payloads. + +The integration validates: + +- Carrier frequency +- Timing structure +- Timing values + +before converting the payload into iTach `sendir` commands. + +The integration does not expose raw iTach protocol commands directly to Home Assistant users. + +--- + +# 12. Capability Detection + +Capability detection uses iTach protocol queries to determine: + +- Available infrared modules +- Connector modes +- Enabled infrared output ports + +Only ports configured for infrared transmission are exposed as Home Assistant entities. + +Ports configured for sensors or LED functionality are ignored. + +Fallback handling exists for incomplete connector-mode reporting. + +--- + +# 13. Concurrency Model + +The integration uses one `ItachClient` per physical iTach device. + +All infrared ports on the same device share the same TCP connection and command-processing pipeline. + +Because the protocol is sequential, infrared commands are serialized through the shared client rather than transmitted concurrently. + +This prevents: + +- Overlapping commands +- Interleaved responses +- Mismatched `completeir` responses +- Stale response handling issues + +Using one shared client per device also centralizes: + +- Reconnect logic +- Protocol sequencing +- Runtime ownership +- Diagnostics + +--- + +# 14. State Management + +Infrared entities are stateless transmitters. + +Because the iTach protocol provides no downstream state feedback from target appliances, entity availability reflects exclusively the connection status to the iTach controller itself. + +Higher-level appliance state tracking must be handled by separate integrations or helper entities. + +--- + +# 15. Diagnostics + +`diagnostics.py` exposes structured diagnostic data for the ConfigEntry. + +Diagnostics include: + +- ConfigEntry data +- Runtime host and port +- Detected infrared ports +- Connector modes +- Enabled infrared ports +- Runtime connection errors + +Sensitive identifiers are redacted where appropriate. + +Diagnostics reuse existing runtime state whenever possible instead of creating unnecessary network connections. + +--- + +# 16. Repairs + +`repairs.py` creates Home Assistant repair issues for important integration problems. + +Repair issues may be created for: + +- Connection failures +- Invalid configuration +- Missing infrared-capable ports + +--- + +# 17. Error Handling + +The `pyitach` library raises typed protocol exceptions such as: + +- `ItachConnectionError` +- `ItachCommandError` +- `ItachResponseError` +- `ItachBusyError` +- `ItachIdentityError` + +The Home Assistant integration layer converts these into: + +- Config flow errors +- Unavailable entities +- Action errors +- Repair issues +- Diagnostics information + +--- + +# 18. Testing Architecture + +The test suite covers: + +- Config flow +- Discovery +- Setup and unload +- Diagnostics +- Repairs +- Infrared entities +- Options flow +- Protocol error paths + +The protocol layer can be tested independently from Home Assistant runtime behavior. + +--- + +# 19. Component Responsibility Table + +| File | Responsibility | +|---|---| +| `config_flow.py` | Setup and validation | +| `options_flow.py` | Runtime configuration updates | +| `discovery.py` | UDP discovery and host tracking | +| `infrared.py` | Physical infrared entities | +| `diagnostics.py` | Diagnostics export | +| `repairs.py` | Repair issue generation | +| `pyitach.client` | TCP transport and serialization | +| `pyitach.protocol` | Protocol encoding/parsing | +| `pyitach.discovery` | Discovery beacon parsing | +| `pyitach.capabilities` | Capability detection | +| `pyitach.identity` | Device ID normalization | + +--- + +# 20. Future Extension Areas + +Potential future enhancements include: + +- Infrared learning support +- Additional Global Caché device models +- Protocol decoding helpers +- Additional diagnostics tooling diff --git a/homeassistant/components/itachip2ir/README.md b/homeassistant/components/itachip2ir/README.md new file mode 100644 index 00000000000000..c649ca2df714bf --- /dev/null +++ b/homeassistant/components/itachip2ir/README.md @@ -0,0 +1,210 @@ +# iTach IP2IR + +The iTach IP2IR integration provides Home Assistant [`infrared`](https://www.home-assistant.io/integrations/infrared/) entities for the Global Caché iTach IP2IR infrared controller. + +The integration automatically detects infrared-capable ports and creates infrared entities for ports configured for infrared transmission. + +## Features + +- DHCP and UDP discovery support +- Automatic creation and reconciliation of infrared entities +- Transmission of infrared timing data +- Diagnostics and repairs support +- Availability tracking and automatic recovery + +## Supported devices + +Only Global Caché iTach IP2IR devices are supported. + +Other Global Caché device types are ignored during discovery. + +## Installation + +Once included in Home Assistant Core, the integration can be added from the Home Assistant user interface. + +Navigate to: + +```text +Settings → Devices & services → Add integration +``` + +Search for: + +```text +iTach IP2IR +``` + +## Setup + +Devices can be added either through discovery or manual configuration. + +Supported discovery methods: +- DHCP discovery +- Global Caché UDP discovery beacons + +The integration uses the device MAC address as the unique identifier to avoid duplicate device creation. + +Discovery may not function across VLANs, segmented networks, restrictive firewall configurations, or Docker bridge networks that block UDP multicast traffic. + +Manual configuration requires: +- host or IP address +- optional TCP port (defaults to 4998) + +The integration attempts to retrieve the device identifier automatically during setup. + +## Infrared entities + +The iTach IP2IR hardware contains three configurable physical ports. Each port may be configured through the device as one of the following modes: + +- IR Out +- IR Blaster Out +- Sensor In +- Sensor Notify +- LED Lighting + +Infrared entities are only created for ports configured as: +- IR Out +- IR Blaster Out + +Example entities: + +```text +IR Port 1 +IR Port 2 +IR Blaster Port 3 +``` + +## Infrared transmission + +The integration uses Home Assistant's [`infrared`](https://www.home-assistant.io/integrations/infrared/) domain and operates on infrared timing data. + +Example: + +```yaml +action: infrared.send_command +target: + entity_id: infrared.ir_port_1 +data: + command: + carrier_frequency: 38000 + timings: + - 9000 + - 4500 + - 560 + - 560 + - 560 + - 1690 + - 560 + - 560 +``` + +Infrared timing data is validated before transmission. + +## Using remotes + +This integration does not create Home Assistant `remote` entities. + +Users who want reusable named commands or `remote.send_command` support can use the separate `virtual_remote` helper integration with the infrared entities exposed by this integration. + +## Port reconciliation + +If a physical port configuration is changed outside of Home Assistant (for example, changing a port from `Sensor In` to `IR Out`), the integration can synchronize these changes through the integration options flow. + +Navigate to: + +```text +Settings → Devices & services → iTach IP2IR → Configure +``` + +When a user triggers a refresh, the integration re-queries the device configuration and automatically adds, enables, or disables entities to match the current hardware configuration. + +## Availability and recovery + +If the iTach device becomes unreachable: +- affected infrared entities are marked unavailable +- service calls raise Home Assistant errors +- warnings are logged + +Entities automatically return to an available state once communication is re-established. + +## Diagnostics + +Diagnostics are available from: + +```text +Settings → Devices & services → iTach IP2IR → Download diagnostics +``` + +Diagnostics include: +- integration configuration +- configured host and TCP port +- detected infrared-capable ports +- connector output modes +- firmware information when available +- connection or firmware query errors + +Diagnostics automatically redact stable identifiers such as: +- MAC addresses +- unique IDs +- device identifiers + +## Troubleshooting + +### The integration cannot connect + +Check: +- the iTach device is powered on +- the configured IP address is correct +- TCP port `4998` is reachable +- firewall rules allow Home Assistant to communicate with the device + +### No devices are discovered + +Check: +- Home Assistant and the iTach device are on the same subnet +- UDP multicast traffic is not blocked +- firewall rules allow UDP discovery traffic +- VLAN or Docker bridge isolation is not preventing discovery traffic + +### Infrared commands do not work + +Check: +- the IR emitter is connected to the expected physical iTach port +- the port is configured as `IR Out` or `IR Blaster Out` +- the infrared timing data is valid +- the carrier frequency matches the target device requirements + +### Enable debug logging + +The integration uses the `pyitach` library for communication with the iTach hardware. + +Add the following to `configuration.yaml`: + +```yaml +logger: + logs: + homeassistant.components.itachip2ir: debug + pyitach: debug +``` + +## Known limitations + +The following limitations currently apply: + +- infrared learning functionality is not implemented +- infrared protocol decoding is not provided +- firmware updates are not supported through this integration + +## Removing the integration + +Navigate to: + +```text +Settings → Devices & services → iTach IP2IR → Delete +``` + +Removing the integration removes: +- associated infrared entities +- stored integration configuration + +The physical iTach hardware configuration is not modified. diff --git a/homeassistant/components/itachip2ir/__init__.py b/homeassistant/components/itachip2ir/__init__.py new file mode 100644 index 00000000000000..b6b300c18d375b --- /dev/null +++ b/homeassistant/components/itachip2ir/__init__.py @@ -0,0 +1,210 @@ +"""Global Caché iTach IP2IR integration.""" + +from dataclasses import dataclass +import logging + +from pyitach import ( + ItachClient, + ItachConnectionError, + ItachError, + async_get_ir_capability, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.core import Event, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +from .const import ( + DISCOVERY, + DOMAIN, + ISSUE_CANNOT_CONNECT, + ISSUE_INVALID_CONFIG, + ISSUE_NO_IR_PORTS, +) +from .discovery import ItachDiscovery +from .repairs import async_create_repair_issue, async_delete_repair_issue + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [Platform.INFRARED] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +@dataclass +class ItachRuntimeData: + """Runtime data for one iTach config entry.""" + + host: str + port: int + device_id: str + ir_module: int + ir_ports: int + ir_enabled_ports: list[int] + ir_connector_modes: dict[str, str] + client: ItachClient + + +type ItachConfigEntry = ConfigEntry[ItachRuntimeData] + + +def _discovery_disabled(hass: HomeAssistant) -> bool: + """Return true when UDP discovery is disabled by tests.""" + return bool(hass.data.get("itachip2ir_disable_discovery", False)) + + +def _issue_id(issue: str, entry: ConfigEntry) -> str: + """Return a per-entry repair issue id.""" + return f"{issue}_{entry.entry_id}" + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the iTach integration.""" + hass.data.setdefault(DOMAIN, {}) + + if not _discovery_disabled(hass): + await _async_start_discovery(hass) + + return True + + +async def _async_start_discovery(hass: HomeAssistant) -> None: + """Start discovery once.""" + domain_data = hass.data.setdefault(DOMAIN, {}) + if DISCOVERY in domain_data: + return + + _LOGGER.debug("Starting iTach discovery") + + discovery = ItachDiscovery(hass) + await discovery.async_start() + domain_data[DISCOVERY] = discovery + + async def _async_stop_discovery(event: Event) -> None: + """Stop discovery when Home Assistant stops.""" + discovery: ItachDiscovery | None = hass.data.get(DOMAIN, {}).pop( + DISCOVERY, + None, + ) + if discovery: + await discovery.async_stop() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop_discovery) + + +async def async_reload_entry(hass: HomeAssistant, entry: ItachConfigEntry) -> None: + """Reload config entry.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_setup_entry(hass: HomeAssistant, entry: ItachConfigEntry) -> bool: + """Initialize the iTach integration from a config entry.""" + if not _discovery_disabled(hass): + await _async_start_discovery(hass) + + if entry.unique_id is None: + async_create_repair_issue( + hass, + _issue_id(ISSUE_INVALID_CONFIG, entry), + translation_key=ISSUE_INVALID_CONFIG, + placeholders={ + "host": str(entry.data.get(CONF_HOST, "unknown")), + "entry_title": entry.title, + "error": "Config entry is missing a unique_id", + }, + ) + raise ValueError("Config entry is missing a unique_id") + + host = str(entry.options.get(CONF_HOST, entry.data[CONF_HOST])) + port = int(entry.options.get(CONF_PORT, entry.data[CONF_PORT])) + + client = ItachClient(host, port) + + try: + ir_capability = await async_get_ir_capability(client) + ir_module = ir_capability.module + ir_ports = ir_capability.ports + client.max_connector = ir_ports + ir_enabled_ports = ir_capability.enabled_ports + ir_connector_modes = ir_capability.connector_modes + except ItachConnectionError as err: + await client.close() + async_create_repair_issue( + hass, + _issue_id(ISSUE_CANNOT_CONNECT, entry), + translation_key=ISSUE_CANNOT_CONNECT, + placeholders={"host": host, "entry_title": entry.title}, + ) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key=ISSUE_CANNOT_CONNECT, + ) from err + except ItachError as err: + await client.close() + async_create_repair_issue( + hass, + _issue_id(ISSUE_INVALID_CONFIG, entry), + translation_key=ISSUE_INVALID_CONFIG, + placeholders={ + "host": host, + "entry_title": entry.title, + "error": str(err), + }, + ) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key=ISSUE_INVALID_CONFIG, + ) from err + + if not ir_enabled_ports: + await client.close() + async_create_repair_issue( + hass, + _issue_id(ISSUE_NO_IR_PORTS, entry), + translation_key=ISSUE_NO_IR_PORTS, + placeholders={"host": host, "entry_title": entry.title}, + ) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key=ISSUE_NO_IR_PORTS, + ) + + async_delete_repair_issue(hass, _issue_id(ISSUE_CANNOT_CONNECT, entry)) + async_delete_repair_issue(hass, _issue_id(ISSUE_INVALID_CONFIG, entry)) + async_delete_repair_issue(hass, _issue_id(ISSUE_NO_IR_PORTS, entry)) + + if all(mode == "UNKNOWN" for mode in ir_connector_modes.values()): + _LOGGER.warning( + "Could not determine iTach IR connector output modes for %s:%s; " + "falling back to all %s connector(s)", + host, + port, + ir_ports, + ) + + entry.runtime_data = ItachRuntimeData( + host=host, + port=port, + device_id=entry.unique_id, + ir_module=ir_module, + ir_ports=ir_ports, + ir_enabled_ports=ir_enabled_ports, + ir_connector_modes=ir_connector_modes, + client=client, + ) + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ItachConfigEntry) -> bool: + """Unload an iTach config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + await entry.runtime_data.client.close() + + return unload_ok diff --git a/homeassistant/components/itachip2ir/config_flow.py b/homeassistant/components/itachip2ir/config_flow.py new file mode 100644 index 00000000000000..c2191b0fc5078d --- /dev/null +++ b/homeassistant/components/itachip2ir/config_flow.py @@ -0,0 +1,476 @@ +"""Config flow for the iTach IP2IR integration.""" + +import logging +from typing import Any + +from pyitach import ( + DEFAULT_PORT, + ItachBusyError, + ItachClient, + ItachCommandError, + ItachConnectionError, + ItachError, + ItachResponseError, + async_get_ir_capability, + normalize_device_id as _pyitach_normalize_device_id, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo + +from .const import DISCOVERY, DOMAIN +from .discovery import ItachDiscovery, async_wait_for_device_id +from .options_flow import ItachOptionsFlow + +_LOGGER = logging.getLogger(__name__) + +CONF_IR_MODULE = "ir_module" +CONF_IR_PORTS = "ir_ports" +CONF_IR_ENABLED_PORTS = "ir_enabled_ports" +CONF_IR_CONNECTOR_MODES = "ir_connector_modes" + + +class CannotConnect(Exception): + """Error to indicate we cannot connect.""" + + +class CannotIdentify(Exception): + """Error to indicate we could not determine a stable device ID.""" + + +class InvalidDeviceId(Exception): + """Error to indicate the user-entered device ID is invalid.""" + + +class NoIrPorts(Exception): + """Error to indicate the device has no usable IR output ports.""" + + +def _raise_no_ir_ports() -> None: + """Raise when the device has no usable IR output ports.""" + raise NoIrPorts + + +def _user_schema( + host: str = "", + port: int = DEFAULT_PORT, + device_id: str = "", +) -> vol.Schema: + """Return user step schema with defaults.""" + return vol.Schema( + { + vol.Required(CONF_HOST, default=host): str, + vol.Optional(CONF_PORT, default=port): int, + vol.Optional(CONF_DEVICE_ID, default=device_id): str, + } + ) + + +def _connection_schema(host: str, port: int = DEFAULT_PORT) -> vol.Schema: + """Return host/port schema.""" + return vol.Schema( + { + vol.Required(CONF_HOST, default=host): str, + vol.Required(CONF_PORT, default=port): int, + } + ) + + +def _normalize_device_id(value: str | None) -> str | None: + """Normalize user-entered device ID into GlobalCache_XXXXXXXXXXXX. + + Accepts GlobalCache_XXXXXXXXXXXX, raw 12-character IDs, and MAC-style + colon/dash-separated values. The MAC-style input is only a convenience; the + canonical config-entry identity is the Global Caché UUID. + """ + if value is None or not value.strip(): + return None + + normalized = _pyitach_normalize_device_id(value) + if normalized is None: + raise InvalidDeviceId + + return normalized + + +async def _validate_device(host: str, port: int) -> dict[str, Any]: + """Validate that the target is a reachable IR-capable iTach device.""" + _LOGGER.debug("Validating iTach IR capability at %s:%s", host, port) + + client = ItachClient(host, port) + + try: + ir_capability = await async_get_ir_capability(client) + + if not ir_capability.enabled_ports: + _raise_no_ir_ports() + + if all(mode == "UNKNOWN" for mode in ir_capability.connector_modes.values()): + _LOGGER.info( + "Could not determine iTach IR connector output modes for %s:%s; " + "falling back to all %s connector(s)", + host, + port, + ir_capability.ports, + ) + except NoIrPorts: + raise + except ItachConnectionError as err: + _LOGGER.debug( + "Failed connecting to iTach while validating IR capability at %s:%s: %s", + host, + port, + err, + ) + raise CannotConnect from err + except ItachCommandError as err: + _LOGGER.debug( + "iTach command error while validating IR capability at %s:%s: %s", + host, + port, + err, + ) + if err.command == "getdevices\r" and err.response == "No IR module found": + raise NoIrPorts from err + raise + except (ItachBusyError, ItachResponseError, ValueError) as err: + _LOGGER.debug( + "Unexpected iTach protocol response while validating IR capability at " + "%s:%s: %s", + host, + port, + err, + ) + raise + except ItachError as err: + _LOGGER.debug( + "Failed communicating with iTach while validating IR capability at " + "%s:%s: %s", + host, + port, + err, + ) + raise CannotConnect from err + else: + return { + CONF_IR_MODULE: ir_capability.module, + CONF_IR_PORTS: ir_capability.ports, + CONF_IR_CONNECTOR_MODES: ir_capability.connector_modes, + CONF_IR_ENABLED_PORTS: ir_capability.enabled_ports, + } + finally: + await client.close() + + +async def _identify_device( + host: str, + user_device_id: str | None, + discovery: ItachDiscovery | None = None, +) -> str: + """Determine stable device ID using user ID, discovery cache, or fallback UDP.""" + normalized = _normalize_device_id(user_device_id) + if normalized is not None: + return normalized + + discovered_id = await async_wait_for_device_id( + host, + timeout=10.0, + discovery=discovery, + ) + if discovered_id is not None: + return discovered_id + + raise CannotIdentify + + +async def _validate_manual_input( + host: str, + port: int, + user_device_id: str | None, + discovery: ItachDiscovery | None = None, +) -> dict[str, Any]: + """Validate manual setup input and determine a stable unique ID.""" + device_info = await _validate_device(host, port) + unique_id = await _identify_device(host, user_device_id, discovery) + + return { + "title": f"iTach IP2IR ({host})", + "unique_id": unique_id, + **device_info, + } + + +def _get_discovery(hass: HomeAssistant) -> ItachDiscovery | None: + """Return the running discovery listener, if available.""" + discovery = hass.data.get(DOMAIN, {}).get(DISCOVERY) + if isinstance(discovery, ItachDiscovery): + return discovery + return None + + +async def _validate_discovered_input( + host: str, + port: int, + unique_id: str, +) -> dict[str, Any]: + """Validate a discovered device using the beacon-provided unique ID.""" + device_info = await _validate_device(host, port) + canonical_unique_id = _normalize_device_id(unique_id) + if canonical_unique_id is None: + raise CannotIdentify + + return { + "title": f"iTach IP2IR ({host})", + "unique_id": canonical_unique_id, + **device_info, + } + + +class Itachip2irConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for iTach IP2IR.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize config flow.""" + self._discovery_info: dict[str, str | int] | None = None + + @staticmethod + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> ItachOptionsFlow: + """Create the options flow.""" + return ItachOptionsFlow(config_entry) + + async def async_step_user( + self, + user_input: dict[str, str | int] | None = None, + ) -> ConfigFlowResult: + """Handle manual setup.""" + errors: dict[str, str] = {} + + host = "" + port = DEFAULT_PORT + device_id_value = "" + + if user_input is not None: + host = str(user_input.get(CONF_HOST, "")) + port = int(user_input.get(CONF_PORT, DEFAULT_PORT)) + + raw_device_id = user_input.get(CONF_DEVICE_ID) + device_id_value = str(raw_device_id) if raw_device_id else "" + + try: + info = await _validate_manual_input( + host, + port, + device_id_value or None, + _get_discovery(self.hass), + ) + except InvalidDeviceId: + errors[CONF_DEVICE_ID] = "invalid_device_id" + except CannotIdentify: + errors["base"] = "cannot_identify" + except NoIrPorts: + errors["base"] = "no_ir_ports" + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error during iTach setup") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(str(info["unique_id"])) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=str(info["title"]), + data={ + CONF_HOST: host, + CONF_PORT: port, + CONF_IR_MODULE: int(info[CONF_IR_MODULE]), + CONF_IR_PORTS: int(info[CONF_IR_PORTS]), + CONF_IR_ENABLED_PORTS: list(info[CONF_IR_ENABLED_PORTS]), + CONF_IR_CONNECTOR_MODES: dict(info[CONF_IR_CONNECTOR_MODES]), + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=_user_schema(host=host, port=port, device_id=device_id_value), + errors=errors, + ) + + async def async_step_reconfigure( + self, + user_input: dict[str, str | int] | None = None, + ) -> ConfigFlowResult: + """Handle reconfiguration of an existing iTach entry.""" + entry = self._get_reconfigure_entry() + + host = str(entry.data[CONF_HOST]) + port = int(entry.data.get(CONF_PORT, DEFAULT_PORT)) + errors: dict[str, str] = {} + + if user_input is not None: + host = str(user_input[CONF_HOST]) + port = int(user_input[CONF_PORT]) + + if host == str(entry.data[CONF_HOST]) and port == int( + entry.data.get(CONF_PORT, DEFAULT_PORT) + ): + return self.async_abort(reason="no_changes") + + try: + info = await _validate_device(host, port) + except NoIrPorts: + errors["base"] = "no_ir_ports" + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error during iTach reconfiguration") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + entry, + data_updates={ + CONF_HOST: host, + CONF_PORT: port, + CONF_IR_MODULE: int(info[CONF_IR_MODULE]), + CONF_IR_PORTS: int(info[CONF_IR_PORTS]), + CONF_IR_ENABLED_PORTS: list(info[CONF_IR_ENABLED_PORTS]), + CONF_IR_CONNECTOR_MODES: dict(info[CONF_IR_CONNECTOR_MODES]), + }, + reason="reconfigure_successful", + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=_connection_schema(host, port), + errors=errors, + ) + + async def async_step_dhcp( + self, + discovery_info: DhcpServiceInfo, + ) -> ConfigFlowResult: + """Handle DHCP discovery.""" + host = discovery_info.ip + macaddress = discovery_info.macaddress + + try: + unique_id = _normalize_device_id(macaddress) + except InvalidDeviceId: + return self.async_abort(reason="cannot_identify") + + if unique_id is None: + return self.async_abort(reason="cannot_identify") + + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + + try: + await _validate_discovered_input( + host=host, + port=DEFAULT_PORT, + unique_id=unique_id, + ) + except CannotIdentify: + return self.async_abort(reason="cannot_identify") + except NoIrPorts: + return self.async_abort(reason="no_ir_ports") + except CannotConnect: + return self.async_abort(reason="cannot_connect") + except Exception: + _LOGGER.exception("Unexpected error during DHCP discovery") + return self.async_abort(reason="unknown") + + self._discovery_info = { + CONF_HOST: host, + CONF_PORT: DEFAULT_PORT, + "unique_id": unique_id, + } + + return await self.async_step_confirm_discovery() + + async def async_step_discovery( + self, + discovery_info: dict[str, str | int], + ) -> ConfigFlowResult: + """Handle discovery from UDP beacon listener.""" + host = str(discovery_info[CONF_HOST]) + port = int(discovery_info.get(CONF_PORT, DEFAULT_PORT)) + + try: + unique_id = _normalize_device_id(str(discovery_info["unique_id"])) + except InvalidDeviceId: + return self.async_abort(reason="cannot_identify") + + if unique_id is None: + return self.async_abort(reason="cannot_identify") + + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + self._discovery_info = { + CONF_HOST: host, + CONF_PORT: port, + "unique_id": unique_id, + } + + return await self.async_step_confirm_discovery() + + async def async_step_confirm_discovery( + self, + user_input: dict[str, str] | None = None, + ) -> ConfigFlowResult: + """Confirm adding a discovered device.""" + if self._discovery_info is None: + return self.async_abort(reason="unknown") + + errors: dict[str, str] = {} + + if user_input is not None: + host = str(self._discovery_info[CONF_HOST]) + port = int(self._discovery_info[CONF_PORT]) + unique_id = str(self._discovery_info["unique_id"]) + + try: + info = await _validate_discovered_input( + host=host, + port=port, + unique_id=unique_id, + ) + except CannotIdentify: + errors["base"] = "cannot_identify" + except NoIrPorts: + errors["base"] = "no_ir_ports" + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error confirming discovered iTach") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(str(info["unique_id"])) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=str(info["title"]), + data={ + CONF_HOST: host, + CONF_PORT: port, + CONF_IR_MODULE: int(info[CONF_IR_MODULE]), + CONF_IR_PORTS: int(info[CONF_IR_PORTS]), + CONF_IR_ENABLED_PORTS: list(info[CONF_IR_ENABLED_PORTS]), + CONF_IR_CONNECTOR_MODES: dict(info[CONF_IR_CONNECTOR_MODES]), + }, + ) + + return self.async_show_form( + step_id="confirm_discovery", + description_placeholders={"host": str(self._discovery_info[CONF_HOST])}, + errors=errors, + ) diff --git a/homeassistant/components/itachip2ir/const.py b/homeassistant/components/itachip2ir/const.py new file mode 100644 index 00000000000000..0ecd3f6c86a326 --- /dev/null +++ b/homeassistant/components/itachip2ir/const.py @@ -0,0 +1,8 @@ +"""Constants for the iTach IP2IR integration.""" + +DOMAIN = "itachip2ir" +DISCOVERY = "discovery" + +ISSUE_CANNOT_CONNECT = "cannot_connect" +ISSUE_NO_IR_PORTS = "no_ir_ports" +ISSUE_INVALID_CONFIG = "invalid_config" diff --git a/homeassistant/components/itachip2ir/diagnostics.py b/homeassistant/components/itachip2ir/diagnostics.py new file mode 100644 index 00000000000000..cfbbbe7a487d57 --- /dev/null +++ b/homeassistant/components/itachip2ir/diagnostics.py @@ -0,0 +1,94 @@ +"""Diagnostics support for iTach IP2IR.""" + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +TO_REDACT = {"device_id", "unique_id", "uuid"} + + +def _extract_client(runtime_data: Any) -> Any | None: + """Extract client from runtime_data safely.""" + if runtime_data is None: + return None + + # Tests: FakeClient directly + if hasattr(runtime_data, "async_get_version"): + return runtime_data + + # Production: wrapper object + if hasattr(runtime_data, "client"): + client = runtime_data.client + if hasattr(client, "async_get_version"): + return client + + return None + + +def _runtime_value(runtime_data: Any, key: str, fallback: Any = None) -> Any: + """Return a value from runtime data when available, else fallback.""" + if runtime_data is None: + return fallback + + return getattr(runtime_data, key, fallback) + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + entry: ConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + runtime_data = getattr(entry, "runtime_data", None) + + device: dict[str, Any] = { + "host": _runtime_value(runtime_data, "host", entry.data.get("host")), + "port": _runtime_value(runtime_data, "port", entry.data.get("port")), + "device_id": entry.unique_id, + "ir_module": _runtime_value( + runtime_data, + "ir_module", + entry.data.get("ir_module"), + ), + "ir_ports": _runtime_value( + runtime_data, + "ir_ports", + entry.data.get("ir_ports"), + ), + "ir_enabled_ports": _runtime_value( + runtime_data, + "ir_enabled_ports", + entry.data.get("ir_enabled_ports"), + ), + "ir_connector_modes": _runtime_value( + runtime_data, + "ir_connector_modes", + entry.data.get("ir_connector_modes"), + ), + "firmware_version": None, + "firmware_error": None, + } + + client = _extract_client(runtime_data) + + if client is not None: + try: + module = device.get("ir_module") or 1 + device["firmware_version"] = await client.async_get_version(module) + except Exception as err: # noqa: BLE001 + device["firmware_error"] = str(err) + + return { + "entry": async_redact_data( + { + "title": entry.title, + "domain": entry.domain, + "data": dict(entry.data), + "options": dict(entry.options), + "unique_id": entry.unique_id, + }, + TO_REDACT, + ), + "device": async_redact_data(device, TO_REDACT), + } diff --git a/homeassistant/components/itachip2ir/discovery.py b/homeassistant/components/itachip2ir/discovery.py new file mode 100644 index 00000000000000..e658e0a896cb8e --- /dev/null +++ b/homeassistant/components/itachip2ir/discovery.py @@ -0,0 +1,317 @@ +"""Home Assistant discovery support for Global Caché iTach IP2IR.""" + +import logging +import time +from typing import TypedDict + +from pyitach import ( + DEFAULT_PORT, + ItachDiscoveryBeacon, + ItachDiscoveryListener, + async_discover_once as _async_discover_once, + normalize_host as _normalize_host, + normalize_uuid as _normalize_uuid, +) + +from homeassistant.config_entries import SOURCE_DISCOVERY, ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +FLOW_THROTTLE_SECONDS = 60.0 +HOST_UPDATE_CONFIRMATIONS = 2 + + +class _PendingHostUpdate(TypedDict): + """Pending host update confirmation state.""" + + host: str + count: int + + +class ItachDiscoveryResult(TypedDict): + """Discovered iTach device.""" + + host: str + uuid: str + model: str + + +def _entry_title_for_host(host: str) -> str: + """Return the default config entry title for a host.""" + return f"iTach IP2IR ({host})" + + +async def async_discover_once(timeout: float = 5.0) -> ItachDiscoveryResult | None: + """Listen briefly for a single iTach beacon.""" + beacon = await _async_discover_once(timeout=timeout) + if beacon is None: + return None + + if beacon.model.lower() != "itachip2ir": + return None + + return { + "host": beacon.host, + "uuid": beacon.uuid, + "model": beacon.model, + } + + +async def async_wait_for_device_id( + host: str, + timeout: float = 5.0, + discovery: ItachDiscovery | None = None, +) -> str | None: + """Wait for a beacon matching a specific host.""" + expected_host = _normalize_host(host) + if expected_host is None: + return None + + if discovery is not None: + known_uuid = discovery.get_known_device_id(expected_host) + if known_uuid is not None: + return known_uuid + + result = await async_discover_once(timeout=timeout) + + if result is None: + return None + + if _normalize_host(result["host"]) == expected_host: + return result["uuid"] + + return None + + +class ItachDiscovery: + """Coordinate Global Caché iTach UDP discovery with Home Assistant.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize discovery.""" + self._hass = hass + self._listener: ItachDiscoveryListener | None = None + self._known_devices: dict[str, str] = {} + self._recent_flows: dict[str, float] = {} + self._pending_host_updates: dict[str, _PendingHostUpdate] = {} + + async def async_start(self) -> None: + """Start the UDP discovery listener.""" + if self._listener is not None: + _LOGGER.debug("iTach discovery listener already running") + return + + _LOGGER.debug("Starting iTach discovery listener") + + listener = ItachDiscoveryListener(self._async_handle_beacon) + if not await listener.async_start(): + return + + self._listener = listener + + async def async_stop(self) -> None: + """Stop the UDP discovery listener.""" + if self._listener is not None: + await self._listener.async_stop() + self._listener = None + + self._known_devices.clear() + self._recent_flows.clear() + self._pending_host_updates.clear() + + def get_known_device_id(self, host: str) -> str | None: + """Return a known canonical UUID for a host if already seen.""" + normalized_host = _normalize_host(host) + if normalized_host is None: + return None + + return self._known_devices.get(normalized_host) + + async def _async_handle_beacon(self, beacon: ItachDiscoveryBeacon) -> None: + """Handle a parsed iTach discovery beacon.""" + beacon_host = _normalize_host(beacon.host) + unique_id = _normalize_uuid(beacon.uuid) + model = beacon.model + + _LOGGER.debug( + "Discovery parsed beacon host=%s model=%s uuid=%s", + beacon_host, + model, + unique_id, + ) + + if beacon_host is None or unique_id is None or model is None: + _LOGGER.debug( + "Ignoring iTach beacon with missing host/model/uuid: host=%s model=%s uuid=%s", + beacon_host, + model, + unique_id, + ) + return + + if model.lower() != "itachip2ir": + _LOGGER.debug( + "Ignoring non-IP2IR Global Caché beacon host=%s model=%s uuid=%s", + beacon_host, + model, + unique_id, + ) + return + + self._known_devices[beacon_host] = unique_id + + configured_entry = self._configured_entry(unique_id) + if configured_entry is not None: + self._update_configured_host(configured_entry, beacon_host) + return + + if self._is_flow_throttled(unique_id): + _LOGGER.debug( + "Discovered iTach flow throttled unique_id=%s host=%s", + unique_id, + beacon_host, + ) + return + + self._mark_flow_started(unique_id) + + _LOGGER.info("Discovered iTach IP2IR at %s; starting config flow", beacon_host) + + try: + result = await self._hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DISCOVERY}, + data={ + CONF_HOST: beacon_host, + CONF_PORT: DEFAULT_PORT, + "unique_id": unique_id, + "model": model, + }, + ) + except Exception: + _LOGGER.exception( + "Failed starting discovery config flow for iTach host=%s unique_id=%s", + beacon_host, + unique_id, + ) + return + + _LOGGER.debug( + "Discovery config flow result for iTach host=%s unique_id=%s: %s", + beacon_host, + unique_id, + result, + ) + + def _configured_entry(self, unique_id: str) -> ConfigEntry | None: + """Return configured entry matching canonical unique ID.""" + normalized_unique_id = _normalize_uuid(unique_id) + + if normalized_unique_id is None: + return None + + for entry in self._hass.config_entries.async_entries(DOMAIN): + if _normalize_uuid(entry.unique_id) == normalized_unique_id: + return entry + + return None + + def _is_already_configured(self, unique_id: str) -> bool: + """Return whether a canonical unique ID is already configured.""" + return self._configured_entry(unique_id) is not None + + def _update_configured_host(self, entry: ConfigEntry, host: str) -> None: + """Update stored host from discovery for an existing entry.""" + if entry.options.get(CONF_HOST): + return + + discovered_host = _normalize_host(host) + current_host = _normalize_host(str(entry.data.get(CONF_HOST, ""))) + + if discovered_host is None or current_host == discovered_host: + return + + if not self._host_update_confirmed(entry.entry_id, discovered_host): + _LOGGER.debug( + "Pending discovered host update for iTach %s from %s to %s", + entry.unique_id, + current_host, + discovered_host, + ) + return + + old_title = _entry_title_for_host(current_host) if current_host else entry.title + new_title = ( + _entry_title_for_host(discovered_host) + if entry.title in {old_title, "iTach IP2IR"} + else entry.title + ) + + self._hass.config_entries.async_update_entry( + entry, + title=new_title, + data={ + **entry.data, + CONF_HOST: discovered_host, + CONF_PORT: entry.data.get(CONF_PORT, DEFAULT_PORT), + }, + ) + self._pending_host_updates.pop(entry.entry_id, None) + + _LOGGER.info( + "Updated discovered host for iTach %s from %s to %s", + entry.unique_id, + current_host, + discovered_host, + ) + + self._schedule_entry_reload(entry) + + def _host_update_confirmed(self, entry_id: str, discovered_host: str) -> bool: + """Return whether a discovered host change has been seen enough times.""" + pending = self._pending_host_updates.get(entry_id) + + if pending is None or pending["host"] != discovered_host: + self._pending_host_updates[entry_id] = { + "host": discovered_host, + "count": 1, + } + return HOST_UPDATE_CONFIRMATIONS <= 1 + + pending["count"] += 1 + return pending["count"] >= HOST_UPDATE_CONFIRMATIONS + + def _schedule_entry_reload(self, entry: ConfigEntry) -> None: + """Schedule a reload for an updated entry.""" + self._hass.config_entries.async_schedule_reload(entry.entry_id) + + def _is_flow_throttled(self, unique_id: str) -> bool: + """Return true if a discovery flow was recently started.""" + self._prune_recent_flows() + + last_started = self._recent_flows.get(unique_id) + if last_started is None: + return False + + return (time.monotonic() - last_started) < FLOW_THROTTLE_SECONDS + + def _mark_flow_started(self, unique_id: str) -> None: + """Record that a discovery flow was started.""" + self._prune_recent_flows() + self._recent_flows[unique_id] = time.monotonic() + + def _prune_recent_flows(self) -> None: + """Remove expired discovery flow throttle entries.""" + now = time.monotonic() + + expired = [ + unique_id + for unique_id, started_at in self._recent_flows.items() + if (now - started_at) >= FLOW_THROTTLE_SECONDS + ] + + for unique_id in expired: + self._recent_flows.pop(unique_id, None) diff --git a/homeassistant/components/itachip2ir/icons.json b/homeassistant/components/itachip2ir/icons.json new file mode 100644 index 00000000000000..79491d6f1135d0 --- /dev/null +++ b/homeassistant/components/itachip2ir/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "infrared": { + "ir_blaster_port": { + "default": "mdi:led-on" + }, + "ir_port": { + "default": "mdi:led-on" + } + } + } +} diff --git a/homeassistant/components/itachip2ir/infrared.py b/homeassistant/components/itachip2ir/infrared.py new file mode 100644 index 00000000000000..9d668ce2af99a0 --- /dev/null +++ b/homeassistant/components/itachip2ir/infrared.py @@ -0,0 +1,180 @@ +"""Infrared entities for Global Caché iTach IP2IR.""" + +import logging + +from pyitach import ( + ItachBusyError, + ItachClient, + ItachCommandError, + ItachConnectionError, + ItachResponseError, +) + +from homeassistant.components.infrared import InfraredCommand, InfraredEntity +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import ItachConfigEntry +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ItachConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up IR emitter entities for configured iTach IR output ports.""" + data = entry.runtime_data + + async_add_entities( + [ + ItachInfraredEntity( + host=data.host, + device_id=data.device_id, + ir_module=data.ir_module, + ir_port=ir_port, + mode=data.ir_connector_modes.get(str(ir_port), "IR"), + client=data.client, + ) + for ir_port in data.ir_enabled_ports + ], + update_before_add=False, + ) + + +def _device_connections(device_id: str) -> set[tuple[str, str]]: + """Return device connections for a canonical Global Caché device ID.""" + if device_id.startswith("GlobalCache_") and len(device_id) == 24: + raw_mac = device_id.removeprefix("GlobalCache_") + if len(raw_mac) == 12: + mac = ":".join(raw_mac[index : index + 2] for index in range(0, 12, 2)) + return {(CONNECTION_NETWORK_MAC, mac)} + + return set() + + +class ItachInfraredEntity(InfraredEntity): + """Represents one IR output port of the iTach device.""" + + _attr_has_entity_name = True + _attr_available = True + + def __init__( + self, + host: str, + device_id: str, + ir_module: int, + ir_port: int, + mode: str, + client: ItachClient, + ) -> None: + """Initialize the IR entity.""" + self._host = host + self._device_id = device_id + self._ir_module = ir_module + self._ir_port = ir_port + self._mode = mode + self._client = client + + self._attr_translation_key = ( + "ir_blaster_port" if mode == "IR_BLASTER" else "ir_port" + ) + self._attr_translation_placeholders = {"port": str(ir_port)} + self._attr_unique_id = f"{device_id}_port_{ir_port}" + + @property + def device_info(self) -> DeviceInfo: + """Return device information for Home Assistant device registry.""" + return DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + connections=_device_connections(self._device_id), + name=f"iTach IP2IR ({self._host})", + manufacturer="Global Caché", + model="iTach IP2IR", + configuration_url=f"http://{self._host}", + ) + + async def async_send_command(self, command: InfraredCommand) -> None: + """Send an IR command via the iTach.""" + try: + carrier_frequency = int(command.modulation) + timings = self._command_to_gc_timings(command, carrier_frequency) + except (TypeError, ValueError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="itach_invalid_command", + translation_placeholders={"error": str(err)}, + ) from err + + try: + await self._client.async_send_ir( + self._ir_module, + self._ir_port, + carrier_frequency, + timings, + ) + except ItachBusyError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="itach_busy", + ) from err + except (ItachCommandError, ItachResponseError, ValueError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="itach_rejected_command", + translation_placeholders={"error": str(err)}, + ) from err + except ItachConnectionError as err: + if self._attr_available: + _LOGGER.warning( + "Lost connection to iTach %s while sending IR on port %s", + self._host, + self._ir_port, + ) + self._attr_available = False + self.async_write_ha_state() + + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="itach_connection_failed", + translation_placeholders={"error": str(err)}, + ) from err + + if not self._attr_available: + _LOGGER.info( + "Connection to iTach %s recovered while sending IR on port %s", + self._host, + self._ir_port, + ) + self._attr_available = True + self.async_write_ha_state() + + def _command_to_gc_timings( + self, + command: InfraredCommand, + carrier_frequency: int, + ) -> list[int]: + """Convert an HA InfraredCommand to Global Caché cycle timings.""" + if carrier_frequency <= 0: + raise ValueError("Carrier frequency must be greater than zero") + + timings: list[int] = [] + + for timing in command.get_raw_timings(): + for duration_us in (timing.high_us, timing.low_us): + if duration_us <= 0: + raise ValueError("IR timing durations must be greater than zero") + + cycles = max(1, round(duration_us * carrier_frequency / 1_000_000)) + timings.append(cycles) + + if not timings: + raise ValueError("IR command contains no usable raw timings") + + return timings diff --git a/homeassistant/components/itachip2ir/manifest.json b/homeassistant/components/itachip2ir/manifest.json new file mode 100644 index 00000000000000..cd240a47bed23a --- /dev/null +++ b/homeassistant/components/itachip2ir/manifest.json @@ -0,0 +1,18 @@ +{ + "domain": "itachip2ir", + "name": "iTach IP2IR", + "codeowners": ["@orandasoft"], + "config_flow": true, + "dependencies": [], + "dhcp": [ + { + "macaddress": "000C1E*" + } + ], + "documentation": "https://www.home-assistant.io/integrations/itachip2ir", + "integration_type": "device", + "iot_class": "local_push", + "loggers": ["homeassistant.components.itachip2ir", "pyitach"], + "quality_scale": "gold", + "requirements": ["pyitach==0.1.0"] +} diff --git a/homeassistant/components/itachip2ir/options_flow.py b/homeassistant/components/itachip2ir/options_flow.py new file mode 100644 index 00000000000000..502fba8773262c --- /dev/null +++ b/homeassistant/components/itachip2ir/options_flow.py @@ -0,0 +1,100 @@ +"""Options flow for Global Caché iTach IP2IR.""" + +import time +from typing import Any + +from pyitach import ( + ItachClient, + ItachConnectionError, + ItachError, + async_get_ir_capability, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.core import callback + +SOURCE_REFRESH_INFRARED_PORTS = "refresh_infrared_ports" +CONF_LAST_PORT_REFRESH = "last_port_refresh" + + +class ItachOptionsFlow(config_entries.OptionsFlow): + """Handle options for iTach IP2IR.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self._config_entry = config_entry + + async def async_step_init( + self, + user_input: dict[str, Any] | None = None, + ) -> config_entries.ConfigFlowResult: + """Manage the options menu.""" + source = self.context.get("source") + + if source == SOURCE_REFRESH_INFRARED_PORTS: + return await self.async_step_refresh_infrared_ports() + + return self.async_show_menu( + step_id="init", + menu_options=[SOURCE_REFRESH_INFRARED_PORTS], + ) + + async def async_step_refresh_infrared_ports( + self, + user_input: dict[str, Any] | None = None, + ) -> config_entries.ConfigFlowResult: + """Query the iTach and reload entities from current port configuration.""" + errors: dict[str, str] = {} + + if user_input is not None: + try: + await self._validate_current_infrared_ports() + except ItachConnectionError: + errors["base"] = "cannot_connect" + except ItachError: + errors["base"] = "unknown" + except ValueError: + errors["base"] = "no_ir_ports" + else: + return self._create_options_entry(force_reload=True) + + return self.async_show_form( + step_id="refresh_infrared_ports", + data_schema=vol.Schema({}), + errors=errors, + ) + + async def _validate_current_infrared_ports(self) -> None: + """Validate current device port configuration has at least one IR port. + + Creating the options entry after this validation triggers the entry update + listener and reloads the integration. The reload re-queries the device and + rebuilds infrared entities from the current port configuration. + """ + host = str( + self._config_entry.options.get("host", self._config_entry.data["host"]) + ) + port = int( + self._config_entry.options.get("port", self._config_entry.data["port"]) + ) + client = ItachClient(host, port) + + try: + ir_capability = await async_get_ir_capability(client) + if not ir_capability.enabled_ports: + raise ValueError("No iTach IR output ports are currently available") + finally: + await client.close() + + @callback + def _create_options_entry( + self, + *, + force_reload: bool = False, + ) -> config_entries.ConfigFlowResult: + """Create the options entry preserving unrelated options.""" + options = dict(self._config_entry.options) + if force_reload: + options[CONF_LAST_PORT_REFRESH] = time.time() + return self.async_create_entry(title="", data=options) diff --git a/homeassistant/components/itachip2ir/py.typed b/homeassistant/components/itachip2ir/py.typed new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/homeassistant/components/itachip2ir/quality_scale.yaml b/homeassistant/components/itachip2ir/quality_scale.yaml new file mode 100644 index 00000000000000..0dd73c2442c0e9 --- /dev/null +++ b/homeassistant/components/itachip2ir/quality_scale.yaml @@ -0,0 +1,112 @@ +# Home Assistant Integration Quality Scale tracking for iTach IP2IR. +# Target tier: gold. +# +# Notes: +# - Test coverage is 95%. +# - The manifest intentionally uses integration_type: device. Each config entry +# represents one physical Global Caché iTach IP2IR device. The IR outputs are +# connectors/entities on that device, not separate managed downstream devices. +# - Home Assistant's Gold tier expects firmware/software updates when possible. +# The Global Caché iTach IP2IR local TCP API used by this integration does not +# expose a documented firmware update mechanism. Firmware updates, when offered +# by the vendor, must be performed outside Home Assistant using Global Caché +# tooling or the device web interface. This limitation is documented in +# README.md. There is no dedicated hassfest quality-scale rule ID for firmware +# updates, so this is recorded here as the required rationale rather than as an +# unsupported extra rule key. + +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: + status: exempt + comment: | + The integration exposes two distinct entity models. + + Infrared entities represent physical iTach infrared-capable output ports + and use Home Assistant entity naming conventions. + + Remote entities are user-created virtual remotes whose names are fully + user-defined and intentionally represent logical devices rather than + physical hardware entities. Automatically prepending the device name would + result in redundant and awkward entity names such as: + + Living Room TV Living Room TV + + Therefore the remote entities intentionally disable has_entity_name. + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + The iTach IP2IR local TCP API used by this integration does not require + authentication credentials or tokens. There is no authentication state + that can expire or require reauthentication. + test-coverage: done + # Gold + devices: done + diagnostics: done + discovery: done + discovery-update-info: done + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: done + entity-category: + status: exempt + comment: | + The integration only exposes user-facing infrared output entities. It does + not create diagnostic, configuration, or system entities that should be + assigned an entity category. + entity-device-class: + status: exempt + comment: | + Home Assistant's infrared entity platform does not define a device class + that applies to these IR output entities. + entity-disabled-by-default: + status: exempt + comment: | + Every entity created by this integration represents a usable IR output + connector. These entities are the primary purpose of the integration and + should be enabled by default. + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: done + stale-devices: + status: exempt + comment: | + The integration represents a single physical iTach IP2IR device per + config entry and creates entities from the currently detected IR output + connectors. Discovery updates the host for the same device ID instead of + creating duplicate devices. There are no dynamically disappearing child + devices that require stale-device cleanup. diff --git a/homeassistant/components/itachip2ir/repairs.py b/homeassistant/components/itachip2ir/repairs.py new file mode 100644 index 00000000000000..f1a27c2b26290e --- /dev/null +++ b/homeassistant/components/itachip2ir/repairs.py @@ -0,0 +1,100 @@ +"""Repairs support for the iTach IP2IR integration.""" + +from typing import Any + +from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from .const import DOMAIN, ISSUE_CANNOT_CONNECT, ISSUE_INVALID_CONFIG, ISSUE_NO_IR_PORTS + +_DEFAULT_PLACEHOLDERS = { + "entry_title": "iTach IP2IR", + "host": "unknown", +} + + +def async_create_repair_issue( + hass: HomeAssistant, + issue_id: str, + *, + translation_key: str, + placeholders: dict[str, str] | None = None, + is_fixable: bool = False, +) -> None: + """Create a Home Assistant repair issue for the integration.""" + translation_placeholders: dict[str, str] | None = placeholders + + # Translation keys intentionally reuse the ISSUE_* identifiers here. + if translation_key in {ISSUE_CANNOT_CONNECT, ISSUE_NO_IR_PORTS}: + translation_placeholders = { + **_DEFAULT_PLACEHOLDERS, + **(placeholders or {}), + } + elif translation_key == ISSUE_INVALID_CONFIG: + invalid_config_placeholders = placeholders or {} + translation_placeholders = { + "entry_title": invalid_config_placeholders.get( + "entry_title", "iTach IP2IR" + ), + "error": invalid_config_placeholders.get("error", "unknown"), + } + + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + is_fixable=is_fixable, + is_persistent=False, + severity=ir.IssueSeverity.ERROR, + translation_key=translation_key, + translation_placeholders=translation_placeholders, + issue_domain=DOMAIN, + ) + + +def async_delete_repair_issue(hass: HomeAssistant, issue_id: str) -> None: + """Delete a Home Assistant repair issue for the integration.""" + ir.async_delete_issue(hass, DOMAIN, issue_id) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, Any] | None, +) -> RepairsFlow: + """Create a repair flow for an iTach IP2IR issue.""" + return ReconfigureRepairFlow(issue_id, data) + + +class ReconfigureRepairFlow(RepairsFlow): + """Repair flow that guides the user to reconfigure or reload the entry.""" + + def __init__(self, issue_id: str, data: dict[str, Any] | None) -> None: + """Initialize the repair flow.""" + self._issue_id = issue_id + self._placeholders = { + **_DEFAULT_PLACEHOLDERS, + **(data or {}), + } + + async def async_step_init( + self, + user_input: dict[str, Any] | None = None, + ) -> RepairsFlowResult: + """Handle the initial repair step.""" + return await self.async_step_confirm(user_input) + + async def async_step_confirm( + self, + user_input: dict[str, Any] | None = None, + ) -> RepairsFlowResult: + """Confirm that the user has handled the repair issue.""" + if user_input is not None: + async_delete_repair_issue(self.hass, self._issue_id) + return self.async_create_entry(title="", data={}) + + return self.async_show_form( + step_id="confirm", + description_placeholders=self._placeholders, + ) diff --git a/homeassistant/components/itachip2ir/strings.json b/homeassistant/components/itachip2ir/strings.json new file mode 100644 index 00000000000000..d98fd92414c2cf --- /dev/null +++ b/homeassistant/components/itachip2ir/strings.json @@ -0,0 +1,117 @@ +{ + "config": { + "abort": { + "already_configured": "This device is already configured.", + "cannot_connect": "Failed to connect.", + "cannot_identify": "Could not identify the device. Enter the MAC address manually.", + "invalid_discovery": "The discovered device information is invalid.", + "no_changes": "No changes were made.", + "no_ir_ports": "No infrared-capable ports were found.", + "not_ip2ir": "The discovered Global Cach\u00e9 device does not expose infrared-capable ports.", + "reconfigure_successful": "Reconfiguration successful.", + "unknown": "Unexpected error." + }, + "error": { + "cannot_connect": "Failed to connect.", + "cannot_identify": "Could not identify the device. Enter the MAC address manually.", + "invalid_device_id": "Enter a valid MAC address.", + "no_ir_ports": "No infrared-capable ports were found.", + "unknown": "Unexpected error." + }, + "step": { + "confirm_discovery": { + "description": "Do you want to set up the discovered iTach IP2IR at {host}?", + "title": "Set up discovered iTach IP2IR" + }, + "reconfigure": { + "data": { + "host": "Host", + "port": "Port" + }, + "data_description": { + "host": "IP address or hostname of the iTach IP2IR device.", + "port": "TCP port used by the iTach device." + }, + "description": "Update the connection details for this iTach IP2IR device.", + "title": "Reconfigure iTach IP2IR" + }, + "user": { + "data": { + "device_id": "MAC address", + "host": "Host", + "port": "Port" + }, + "data_description": { + "device_id": "MAC address or Global Cach\u00e9 device identifier used to uniquely identify this device.", + "host": "IP address or hostname of the iTach IP2IR device.", + "port": "TCP port used by the iTach device." + }, + "description": "Connect to your Global Cach\u00e9 iTach IP2IR device.", + "title": "Set up iTach IP2IR" + } + } + }, + "entity": { + "infrared": { + "ir_blaster_port": { + "name": "IR Blaster Port {port}" + }, + "ir_port": { + "name": "IR Port {port}" + } + } + }, + "exceptions": { + "itach_busy": { + "message": "The iTach infrared port is busy." + }, + "itach_connection_failed": { + "message": "Failed to communicate with the iTach device: {error}" + }, + "itach_invalid_command": { + "message": "The infrared command is invalid: {error}" + }, + "itach_rejected_command": { + "message": "The iTach device rejected the command: {error}" + } + }, + "issues": { + "cannot_connect": { + "description": "Home Assistant cannot connect to **{entry_title}**. Check that the iTach device is powered on, reachable from Home Assistant, and using the configured network address. After fixing the network or device issue, reload the integration.", + "title": "iTach IP2IR cannot be reached" + }, + "invalid_config": { + "description": "Home Assistant could not set up **{entry_title}** because the stored configuration or device response is invalid. Reconfigure the integration or reload it after fixing the device or network configuration. Error: {error}", + "title": "iTach IP2IR configuration problem" + }, + "no_ir_ports": { + "description": "**{entry_title}** does not currently expose any infrared-capable ports. Check the iTach port configuration and make sure at least one port is configured as `IR Out` or `IR Blaster Out`, then reload the integration.", + "title": "No infrared-capable iTach ports are available" + } + }, + "options": { + "abort": { + "refresh_successful": "Infrared entities refreshed." + }, + "error": { + "cannot_connect": "Failed to connect.", + "no_ir_ports": "No infrared-capable ports were found.", + "unknown": "Unexpected error." + }, + "progress": { + "refresh_infrared_ports": "Refreshing infrared entities..." + }, + "step": { + "init": { + "menu_options": { + "refresh_infrared_ports": "Refresh infrared entities" + }, + "title": "Manage iTach IP2IR" + }, + "refresh_infrared_ports": { + "description": "Query the iTach device and reconcile Home Assistant infrared entities with the current port configuration.", + "title": "Refresh infrared entities" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b5e0330d3ae172..591d8b15a01da9 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -371,6 +371,7 @@ "iss", "ista_ecotrend", "isy994", + "itachip2ir", "ituran", "izone", "jellyfin", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 8653707602498c..fd604147c9db9b 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -384,6 +384,10 @@ "hostname": "polisy*", "macaddress": "000DB9*", }, + { + "domain": "itachip2ir", + "macaddress": "000C1E*", + }, { "domain": "knocki", "hostname": "knc*", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index a5f9fd5bcffe1f..f3587f319a2130 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3414,6 +3414,12 @@ "config_flow": true, "iot_class": "local_push" }, + "itachip2ir": { + "name": "iTach IP2IR", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "ituran": { "name": "Ituran", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 3ae2dfaff924bd..a199cd78a4506c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2252,6 +2252,9 @@ pyiss==1.0.1 # homeassistant.components.isy994 pyisy==3.6.1 +# homeassistant.components.itachip2ir +pyitach==0.1.0 + # homeassistant.components.itach pyitachip2ir==0.0.7 diff --git a/tests/components/itachip2ir/__init__.py b/tests/components/itachip2ir/__init__.py new file mode 100644 index 00000000000000..1bca844abe6067 --- /dev/null +++ b/tests/components/itachip2ir/__init__.py @@ -0,0 +1 @@ +"""Tests for the iTach IP2IR integration.""" diff --git a/tests/components/itachip2ir/conftest.py b/tests/components/itachip2ir/conftest.py new file mode 100644 index 00000000000000..c8b40375c99f29 --- /dev/null +++ b/tests/components/itachip2ir/conftest.py @@ -0,0 +1,48 @@ +"""Fixtures for the iTach IP2IR tests.""" + +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.core import HomeAssistant + + +@pytest.fixture(autouse=True) +def auto_enable_custom_integrations(enable_custom_integrations: None) -> None: + """Enable custom integrations.""" + return + + +@pytest.fixture(autouse=True) +def disable_udp_discovery_in_tests(hass: HomeAssistant): + """Disable UDP discovery globally.""" + hass.data["itachip2ir_disable_discovery"] = True + + +@pytest.fixture(autouse=True) +def mock_itach_client(monkeypatch: pytest.MonkeyPatch): + """Mock TCP client globally.""" + + class FakeClient: + def __init__(self, host, port) -> None: + self.host = host + self.port = port + self.close = AsyncMock() + + async def async_get_ir_module(self): + return 1, 3 + + async def async_get_ir_connector_modes(self, module, ports): + return {1: "IR", 2: "SENSOR", 3: "IR_BLASTER"} + + async def async_get_version(self, module): + return "710-1000-23" + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.ItachClient", + FakeClient, + ) + monkeypatch.setattr( + "homeassistant.components.itachip2ir.config_flow.ItachClient", + FakeClient, + ) diff --git a/tests/components/itachip2ir/test_config_flow.py b/tests/components/itachip2ir/test_config_flow.py new file mode 100644 index 00000000000000..614e3ade1ea49e --- /dev/null +++ b/tests/components/itachip2ir/test_config_flow.py @@ -0,0 +1,1377 @@ +"""Tests for the iTach IP2IR config flow.""" + +from typing import cast +from unittest.mock import AsyncMock + +from pyitach import ( + DEFAULT_PORT, + ItachCommandError, + ItachConnectionError, + ItachError, + ItachResponseError, +) +import pytest + +from homeassistant import config_entries +from homeassistant.components.itachip2ir.config_flow import ( + CONF_DEVICE_ID, + CONF_IR_CONNECTOR_MODES, + CONF_IR_ENABLED_PORTS, + CONF_IR_MODULE, + CONF_IR_PORTS, + CannotConnect, + CannotIdentify, + InvalidDeviceId, + Itachip2irConfigFlow, + NoIrPorts, + _get_discovery, + _identify_device, + _normalize_device_id, + _validate_device, + _validate_discovered_input, + _validate_manual_input, +) +from homeassistant.components.itachip2ir.const import DISCOVERY, DOMAIN +from homeassistant.components.itachip2ir.discovery import ItachDiscovery +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo + +from tests.common import MockConfigEntry + +HOST = "192.168.1.211" +PORT = DEFAULT_PORT +NEW_HOST = "192.168.1.250" +NEW_PORT = 5998 +UNIQUE_ID = "GlobalCache_000C1E123456" +MAC = "000C1E123456" +DHCP_MAC = "000c1e123456" + + +def _dhcp_info( + *, + host: str = HOST, + macaddress: str = DHCP_MAC, + hostname: str = "itachip2ir", +) -> DhcpServiceInfo: + """Return fake DHCP discovery info.""" + return DhcpServiceInfo( + ip=host, + hostname=hostname, + macaddress=macaddress, + ) + + +class FakeClient: + """Fake iTach client.""" + + def __init__( + self, + host: str, + port: int, + *, + ir_module: int = 1, + ir_ports: int = 3, + connector_modes: dict[int, str] | None = None, + module_error: Exception | None = None, + modes_error: Exception | None = None, + ) -> None: + """Initialize fake client.""" + self.host = host + self.port = port + self.ir_module = ir_module + self.ir_ports = ir_ports + self.connector_modes = ( + {1: "IR", 2: "SENSOR", 3: "IR_BLASTER"} + if connector_modes is None + else connector_modes + ) + self.module_error = module_error + self.modes_error = modes_error + self.close = AsyncMock() + + async def async_get_ir_module(self) -> tuple[int, int]: + """Return fake IR module information.""" + if self.module_error is not None: + raise self.module_error + + return self.ir_module, self.ir_ports + + async def async_get_ir_connector_modes( + self, + module: int, + ports: int, + ) -> dict[int, str]: + """Return fake IR connector modes.""" + if self.modes_error is not None: + raise self.modes_error + + return self.connector_modes + + +def _patch_client( + monkeypatch, + *, + ir_module: int = 1, + ir_ports: int = 3, + connector_modes: dict[int, str] | None = None, + module_error: Exception | None = None, + modes_error: Exception | None = None, +) -> None: + """Patch config flow ItachClient.""" + + class PatchedFakeClient(FakeClient): + def __init__(self, host: str, port: int) -> None: + super().__init__( + host, + port, + ir_module=ir_module, + ir_ports=ir_ports, + connector_modes=connector_modes, + module_error=module_error, + modes_error=modes_error, + ) + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.config_flow.ItachClient", + PatchedFakeClient, + ) + + +def _make_entry( + *, + data: dict | None = None, + options: dict | None = None, + unique_id: str = UNIQUE_ID, +) -> MockConfigEntry: + """Create a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=unique_id, + data=data + or { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_IR_MODULE: 1, + CONF_IR_PORTS: 3, + CONF_IR_ENABLED_PORTS: [1, 3], + CONF_IR_CONNECTOR_MODES: { + "1": "IR", + "2": "SENSOR", + "3": "IR_BLASTER", + }, + }, + options=options or {}, + title="iTach IP2IR", + ) + + +def test_normalize_device_id_accepts_plain_mac() -> None: + """Test normalizing a plain MAC address.""" + assert _normalize_device_id(MAC) == UNIQUE_ID + + +def test_normalize_device_id_accepts_colon_mac() -> None: + """Test normalizing a colon-separated MAC address.""" + assert _normalize_device_id("00:0c:1e:12:34:56") == UNIQUE_ID + + +def test_normalize_device_id_accepts_dash_mac() -> None: + """Test normalizing a dash-separated MAC address.""" + assert _normalize_device_id("00-0c-1e-12-34-56") == UNIQUE_ID + + +def test_normalize_device_id_accepts_globalcache_prefix() -> None: + """Test normalizing a GlobalCache-prefixed device ID.""" + assert _normalize_device_id("GlobalCache_000c1e123456") == UNIQUE_ID + + +def test_normalize_device_id_empty_returns_none() -> None: + """Test empty device ID returns None.""" + assert _normalize_device_id(None) is None + assert _normalize_device_id("") is None + assert _normalize_device_id(" ") is None + + +@pytest.mark.parametrize( + "value", + [ + "not-a-mac", + "000C1E12345", + "000C1E1234567", + "GG0C1E123456", + "000000000000", + ], +) +def test_normalize_device_id_rejects_invalid_values(value: str) -> None: + """Test invalid MAC values are rejected.""" + with pytest.raises(InvalidDeviceId): + _normalize_device_id(value) + + +async def test_validate_device_filters_ir_output_modes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test device validation returns only IR output ports.""" + _patch_client( + monkeypatch, + connector_modes={ + 1: "IR", + 2: "SENSOR", + 3: "IR_BLASTER", + }, + ) + + result = await _validate_device(HOST, PORT) + + assert result == { + CONF_IR_MODULE: 1, + CONF_IR_PORTS: 3, + CONF_IR_CONNECTOR_MODES: { + "1": "IR", + "2": "SENSOR", + "3": "IR_BLASTER", + }, + CONF_IR_ENABLED_PORTS: [1, 3], + } + + +async def test_validate_device_get_ir_fallback(monkeypatch: pytest.MonkeyPatch) -> None: + """Test device validation falls back when get_IR returns no modes.""" + _patch_client( + monkeypatch, + ir_module=1, + ir_ports=3, + connector_modes={}, + ) + + result = await _validate_device(HOST, PORT) + + assert result == { + CONF_IR_MODULE: 1, + CONF_IR_PORTS: 3, + CONF_IR_CONNECTOR_MODES: { + "1": "UNKNOWN", + "2": "UNKNOWN", + "3": "UNKNOWN", + }, + CONF_IR_ENABLED_PORTS: [1, 2, 3], + } + + +async def test_validate_device_no_output_ports(monkeypatch: pytest.MonkeyPatch) -> None: + """Test device validation fails when no connector is an IR output.""" + _patch_client( + monkeypatch, + connector_modes={ + 1: "SENSOR", + 2: "SENSOR", + 3: "SENSOR", + }, + ) + + with pytest.raises(NoIrPorts): + await _validate_device(HOST, PORT) + + +async def test_user_flow_success_with_manual_mac( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test manual user flow succeeds with a provided MAC address.""" + _patch_client(monkeypatch) + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.config_flow.async_wait_for_device_id", + AsyncMock(return_value=None), + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_DEVICE_ID: MAC, + }, + ) + + assert result["type"] == "create_entry" + assert result["title"] == f"iTach IP2IR ({HOST})" + assert result["data"] == { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_IR_MODULE: 1, + CONF_IR_PORTS: 3, + CONF_IR_ENABLED_PORTS: [1, 3], + CONF_IR_CONNECTOR_MODES: { + "1": "IR", + "2": "SENSOR", + "3": "IR_BLASTER", + }, + } + + +async def test_user_flow_invalid_mac( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test manual user flow shows invalid_device_id for invalid MAC.""" + _patch_client(monkeypatch) + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.config_flow.async_wait_for_device_id", + AsyncMock(return_value=None), + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_DEVICE_ID: "not-a-mac", + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == { + CONF_DEVICE_ID: "invalid_device_id", + } + + +async def test_user_flow_cannot_identify_without_discovery_or_mac( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test manual user flow fails when no discovery ID or MAC is available.""" + _patch_client(monkeypatch) + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.config_flow.async_wait_for_device_id", + AsyncMock(return_value=None), + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == { + "base": "cannot_identify", + } + + +async def test_user_flow_cannot_connect( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test manual user flow shows cannot_connect when validation fails.""" + _patch_client( + monkeypatch, + module_error=ItachError("cannot connect"), + ) + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.config_flow.async_wait_for_device_id", + AsyncMock(return_value=UNIQUE_ID), + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_DEVICE_ID: MAC, + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == { + "base": "cannot_connect", + } + + +async def test_user_flow_no_ir_ports( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test user flow shows no_ir_ports when no usable IR outputs.""" + _patch_client( + monkeypatch, + connector_modes={ + 1: "SENSOR", + 2: "SENSOR", + 3: "SENSOR", + }, + ) + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.config_flow.async_wait_for_device_id", + AsyncMock(return_value=UNIQUE_ID), + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_DEVICE_ID: MAC, + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {"base": "no_ir_ports"} + + +async def test_user_flow_duplicate_abort( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test manual user flow aborts for an already configured device.""" + _patch_client(monkeypatch) + + entry = _make_entry() + entry.add_to_hass(hass) + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.config_flow.async_wait_for_device_id", + AsyncMock(return_value=UNIQUE_ID), + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_DEVICE_ID: MAC, + }, + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_dhcp_flow_success( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test DHCP discovery confirms and creates an entry.""" + _patch_client(monkeypatch) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=_dhcp_info(), + ) + + assert result["type"] == "form" + assert result["step_id"] == "confirm_discovery" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == f"iTach IP2IR ({HOST})" + assert result["data"] == { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_IR_MODULE: 1, + CONF_IR_PORTS: 3, + CONF_IR_ENABLED_PORTS: [1, 3], + CONF_IR_CONNECTOR_MODES: { + "1": "IR", + "2": "SENSOR", + "3": "IR_BLASTER", + }, + } + + +async def test_dhcp_flow_updates_existing_host( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test DHCP discovery updates an existing entry host by unique ID.""" + _patch_client(monkeypatch) + + entry = _make_entry() + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=_dhcp_info(host=NEW_HOST), + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + assert entry.data[CONF_HOST] == NEW_HOST + + +async def test_dhcp_flow_invalid_mac_aborts(hass: HomeAssistant) -> None: + """Test DHCP discovery aborts when the MAC address cannot be normalized.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=_dhcp_info(macaddress="not-a-mac"), + ) + + assert result["type"] == "abort" + assert result["reason"] == "cannot_identify" + + +async def test_dhcp_flow_cannot_connect_aborts( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test DHCP discovery aborts when validation cannot connect.""" + _patch_client( + monkeypatch, + module_error=ItachError("cannot connect"), + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=_dhcp_info(), + ) + + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + + +async def test_dhcp_flow_no_ir_ports_aborts( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test DHCP discovery aborts for non-IR Global Caché devices.""" + _patch_client(monkeypatch, connector_modes={1: "SENSOR", 2: "SENSOR", 3: "SENSOR"}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=_dhcp_info(), + ) + + assert result["type"] == "abort" + assert result["reason"] == "no_ir_ports" + + +async def test_discovery_flow_success( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test discovery flow confirms and creates an entry.""" + _patch_client(monkeypatch) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DISCOVERY}, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + "unique_id": UNIQUE_ID, + "model": "iTachIP2IR", + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "confirm_discovery" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == f"iTach IP2IR ({HOST})" + assert result["data"] == { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_IR_MODULE: 1, + CONF_IR_PORTS: 3, + CONF_IR_ENABLED_PORTS: [1, 3], + CONF_IR_CONNECTOR_MODES: { + "1": "IR", + "2": "SENSOR", + "3": "IR_BLASTER", + }, + } + + +async def test_discovery_flow_duplicate_abort( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test discovery flow aborts for an already configured device.""" + _patch_client(monkeypatch) + + entry = _make_entry() + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DISCOVERY}, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + "unique_id": UNIQUE_ID, + "model": "iTachIP2IR", + }, + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_discovery_confirm_cannot_connect( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test discovered device confirmation shows cannot_connect on validation error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DISCOVERY}, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + "unique_id": UNIQUE_ID, + "model": "iTachIP2IR", + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "confirm_discovery" + + _patch_client( + monkeypatch, + module_error=ItachError("cannot connect"), + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "confirm_discovery" + assert result["errors"] == { + "base": "cannot_connect", + } + + +async def test_discovery_confirm_no_ir_ports( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test discovery confirm shows no_ir_ports when no usable IR outputs.""" + _patch_client( + monkeypatch, + connector_modes={ + 1: "SENSOR", + 2: "SENSOR", + 3: "SENSOR", + }, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DISCOVERY}, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + "unique_id": UNIQUE_ID, + "model": "iTachIP2IR", + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "confirm_discovery" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "confirm_discovery" + assert result["errors"] == {"base": "no_ir_ports"} + + +async def test_reconfigure_flow_success( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test reconfigure flow updates host, port, and IR capability data.""" + _patch_client( + monkeypatch, + connector_modes={ + 1: "IR", + 2: "IR", + 3: "SENSOR", + }, + ) + + entry = _make_entry() + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + data=None, + ) + + assert result["type"] == "form" + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: NEW_HOST, + CONF_PORT: NEW_PORT, + }, + ) + + assert result["type"] == "abort" + assert result["reason"] == "reconfigure_successful" + + assert entry.data[CONF_HOST] == NEW_HOST + assert entry.data[CONF_PORT] == NEW_PORT + assert entry.data[CONF_IR_MODULE] == 1 + assert entry.data[CONF_IR_PORTS] == 3 + assert entry.data[CONF_IR_ENABLED_PORTS] == [1, 2] + assert entry.data[CONF_IR_CONNECTOR_MODES] == { + "1": "IR", + "2": "IR", + "3": "SENSOR", + } + + +async def test_reconfigure_flow_cannot_connect( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test reconfigure flow keeps form open when validation fails.""" + _patch_client( + monkeypatch, + module_error=ItachError("cannot connect"), + ) + + entry = _make_entry() + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + data=None, + ) + + assert result["type"] == "form" + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: NEW_HOST, + CONF_PORT: NEW_PORT, + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "reconfigure" + assert result["errors"] == { + "base": "cannot_connect", + } + + assert entry.data[CONF_HOST] == HOST + assert entry.data[CONF_PORT] == PORT + + +async def test_reconfigure_flow_no_ir_ports( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test reconfigure shows no_ir_ports when no usable IR outputs.""" + _patch_client( + monkeypatch, + connector_modes={ + 1: "SENSOR", + 2: "SENSOR", + 3: "SENSOR", + }, + ) + + entry = _make_entry() + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + data=None, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: NEW_HOST, + CONF_PORT: NEW_PORT, + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "reconfigure" + assert result["errors"] == {"base": "no_ir_ports"} + + +async def test_reconfigure_flow_uses_existing_defaults( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test reconfigure form defaults come from existing entry data.""" + _patch_client(monkeypatch) + + entry = _make_entry( + data={ + CONF_HOST: NEW_HOST, + CONF_PORT: NEW_PORT, + CONF_IR_MODULE: 1, + CONF_IR_PORTS: 3, + CONF_IR_ENABLED_PORTS: [1, 3], + CONF_IR_CONNECTOR_MODES: { + "1": "IR", + "2": "SENSOR", + "3": "IR_BLASTER", + }, + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + data=None, + ) + + assert result["type"] == "form" + assert result["step_id"] == "reconfigure" + + data_schema = result["data_schema"] + assert data_schema is not None + + schema = data_schema.schema + defaults = {key.schema: key.default() for key in schema} + + assert defaults[CONF_HOST] == NEW_HOST + assert defaults[CONF_PORT] == NEW_PORT + + +async def test_validate_device_closes_client_on_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test device validation closes the client after a successful probe.""" + clients: list[FakeClient] = [] + + class PatchedFakeClient(FakeClient): + def __init__(self, host: str, port: int) -> None: + super().__init__(host, port) + clients.append(self) + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.config_flow.ItachClient", + PatchedFakeClient, + ) + + await _validate_device(HOST, PORT) + + clients[0].close.assert_awaited_once() + + +async def test_validate_device_closes_client_on_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test device validation closes the client after a failed probe.""" + clients: list[FakeClient] = [] + + class PatchedFakeClient(FakeClient): + def __init__(self, host: str, port: int) -> None: + super().__init__(host, port, module_error=ItachError("boom")) + clients.append(self) + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.config_flow.ItachClient", + PatchedFakeClient, + ) + + with pytest.raises(CannotConnect): + await _validate_device(HOST, PORT) + + clients[0].close.assert_awaited_once() + + +async def test_identify_device_uses_manual_id_without_discovery( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test manual device ID is used before discovery is attempted.""" + wait = AsyncMock(return_value="GlobalCache_SHOULDNOTUSE") + monkeypatch.setattr( + "homeassistant.components.itachip2ir.config_flow.async_wait_for_device_id", + wait, + ) + + assert await _identify_device(HOST, MAC) == UNIQUE_ID + wait.assert_not_awaited() + + +async def test_identify_device_uses_discovery_when_manual_id_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test discovery is used when no manual device ID is supplied.""" + wait = AsyncMock(return_value=UNIQUE_ID) + monkeypatch.setattr( + "homeassistant.components.itachip2ir.config_flow.async_wait_for_device_id", + wait, + ) + discovery = cast(ItachDiscovery, object()) + + assert await _identify_device(HOST, None, discovery) == UNIQUE_ID + wait.assert_awaited_once_with(HOST, timeout=10.0, discovery=discovery) + + +async def test_identify_device_raises_when_missing_manual_and_discovery( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test identification fails when neither manual ID nor discovery are available.""" + monkeypatch.setattr( + "homeassistant.components.itachip2ir.config_flow.async_wait_for_device_id", + AsyncMock(return_value=None), + ) + + with pytest.raises(CannotIdentify): + await _identify_device(HOST, None) + + +async def test_validate_manual_input_returns_capability_data( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test manual validation returns title, unique ID, and capability data.""" + _patch_client(monkeypatch) + monkeypatch.setattr( + "homeassistant.components.itachip2ir.config_flow.async_wait_for_device_id", + AsyncMock(return_value=UNIQUE_ID), + ) + + result = await _validate_manual_input(HOST, PORT, None) + + assert result == { + "title": f"iTach IP2IR ({HOST})", + "unique_id": UNIQUE_ID, + CONF_IR_MODULE: 1, + CONF_IR_PORTS: 3, + CONF_IR_CONNECTOR_MODES: { + "1": "IR", + "2": "SENSOR", + "3": "IR_BLASTER", + }, + CONF_IR_ENABLED_PORTS: [1, 3], + } + + +async def test_validate_discovered_input_rejects_invalid_unique_id( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test discovered validation rejects invalid unique IDs.""" + _patch_client(monkeypatch) + + with pytest.raises(InvalidDeviceId): + await _validate_discovered_input(HOST, PORT, "not-a-device-id") + + +async def test_get_discovery_returns_listener_when_available( + hass: HomeAssistant, +) -> None: + """Test running discovery listener is returned from hass data.""" + discovery = ItachDiscovery(hass) + hass.data.setdefault(DOMAIN, {})[DISCOVERY] = discovery + + assert _get_discovery(hass) is discovery + + +async def test_get_discovery_returns_none_for_missing_or_wrong_type( + hass: HomeAssistant, +) -> None: + """Test discovery lookup ignores missing or invalid data.""" + assert _get_discovery(hass) is None + + hass.data.setdefault(DOMAIN, {})[DISCOVERY] = object() + assert _get_discovery(hass) is None + + +async def test_user_flow_initial_form(hass: HomeAssistant) -> None: + """Test the initial user step shows the setup form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {} + + +async def test_user_flow_unknown_error( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test manual user flow handles unexpected errors.""" + monkeypatch.setattr( + "homeassistant.components.itachip2ir.config_flow._validate_manual_input", + AsyncMock(side_effect=RuntimeError("boom")), + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_DEVICE_ID: MAC, + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {"base": "unknown"} + + +async def test_reconfigure_flow_no_change_aborts( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test reconfigure aborts when host and port are unchanged.""" + _patch_client(monkeypatch) + entry = _make_entry() + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + data=None, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: HOST, + CONF_PORT: PORT, + }, + ) + + assert result["type"] == "abort" + assert result["reason"] == "no_changes" + + +async def test_reconfigure_flow_unknown_error( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test reconfigure handles unexpected validation errors.""" + entry = _make_entry() + entry.add_to_hass(hass) + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.config_flow._validate_device", + AsyncMock(side_effect=RuntimeError("boom")), + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + data=None, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: NEW_HOST, + CONF_PORT: NEW_PORT, + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "reconfigure" + assert result["errors"] == {"base": "unknown"} + + +async def test_discovery_flow_invalid_unique_id_aborts(hass: HomeAssistant) -> None: + """Test discovery flow aborts when unique ID is invalid.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DISCOVERY}, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + "unique_id": "not-a-device-id", + "model": "iTachIP2IR", + }, + ) + + assert result["type"] == "abort" + assert result["reason"] == "cannot_identify" + + +async def test_discovery_confirm_unknown_error( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test discovery confirmation handles unexpected errors.""" + _patch_client(monkeypatch) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DISCOVERY}, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + "unique_id": UNIQUE_ID, + "model": "iTachIP2IR", + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "confirm_discovery" + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.config_flow._validate_discovered_input", + AsyncMock(side_effect=RuntimeError("boom")), + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "confirm_discovery" + assert result["errors"] == {"base": "unknown"} + + +async def test_dhcp_flow_cannot_identify_after_validation_aborts( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test DHCP discovery aborts if validation cannot identify the device.""" + monkeypatch.setattr( + "homeassistant.components.itachip2ir.config_flow._validate_discovered_input", + AsyncMock(side_effect=CannotIdentify), + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=_dhcp_info(), + ) + + assert result["type"] == "abort" + assert result["reason"] == "cannot_identify" + + +async def test_dhcp_flow_unknown_error_aborts( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test DHCP discovery aborts cleanly for unexpected validation errors.""" + monkeypatch.setattr( + "homeassistant.components.itachip2ir.config_flow._validate_discovered_input", + AsyncMock(side_effect=RuntimeError("boom")), + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=_dhcp_info(), + ) + + assert result["type"] == "abort" + assert result["reason"] == "unknown" + + +async def test_dhcp_flow_missing_mac_aborts(hass: HomeAssistant) -> None: + """Test DHCP discovery aborts when DHCP does not provide a MAC address.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=_dhcp_info(macaddress=""), + ) + + assert result["type"] == "abort" + assert result["reason"] == "cannot_identify" + + +async def test_reconfigure_flow_preserves_entry_data_on_validation_failure( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test failed reconfigure attempts do not mutate stored entry data.""" + entry = _make_entry() + entry.add_to_hass(hass) + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.config_flow._validate_device", + AsyncMock(side_effect=CannotConnect), + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + data=None, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: NEW_HOST, CONF_PORT: NEW_PORT}, + ) + + assert result["type"] == "form" + assert result["errors"] == {"base": "cannot_connect"} + assert entry.data[CONF_HOST] == HOST + assert entry.data[CONF_PORT] == PORT + + +async def test_validate_device_connection_error_maps_to_cannot_connect( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test connection errors during validation map to CannotConnect.""" + _patch_client(monkeypatch, module_error=ItachConnectionError("offline")) + + with pytest.raises(CannotConnect): + await _validate_device(HOST, PORT) + + +async def test_validate_device_no_ir_module_command_error_maps_to_no_ir_ports( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test missing IR module command error maps to NoIrPorts.""" + _patch_client( + monkeypatch, + module_error=ItachCommandError("No IR module found", "getdevices\r"), + ) + + with pytest.raises(NoIrPorts): + await _validate_device(HOST, PORT) + + +async def test_validate_device_other_command_error_is_not_remapped( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test unrelated command errors are re-raised unchanged.""" + error = ItachCommandError("ERR_01", "getdevices\r") + _patch_client(monkeypatch, module_error=error) + + with pytest.raises(ItachCommandError) as exc_info: + await _validate_device(HOST, PORT) + + assert exc_info.value is error + + +async def test_validate_device_protocol_error_is_not_remapped( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test protocol response errors are re-raised unchanged.""" + error = ItachResponseError("bad response") + _patch_client(monkeypatch, module_error=error) + + with pytest.raises(ItachResponseError) as exc_info: + await _validate_device(HOST, PORT) + + assert exc_info.value is error + + +async def test_validate_discovered_input_blank_unique_id_raises_cannot_identify( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test discovered validation rejects a blank normalized unique ID.""" + monkeypatch.setattr( + "homeassistant.components.itachip2ir.config_flow._validate_device", + AsyncMock( + return_value={ + CONF_IR_MODULE: 1, + CONF_IR_PORTS: 3, + CONF_IR_ENABLED_PORTS: [1, 3], + CONF_IR_CONNECTOR_MODES: {"1": "IR", "3": "IR_BLASTER"}, + } + ), + ) + + with pytest.raises(CannotIdentify): + await _validate_discovered_input(HOST, PORT, "") + + +async def test_discovery_flow_blank_unique_id_aborts_cannot_identify( + hass: HomeAssistant, +) -> None: + """Test UDP discovery aborts when the normalized unique ID is blank.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DISCOVERY}, + data={CONF_HOST: HOST, CONF_PORT: PORT, "unique_id": "", "model": "iTachIP2IR"}, + ) + + assert result["type"] == "abort" + assert result["reason"] == "cannot_identify" + + +async def test_confirm_discovery_without_discovery_info_aborts_unknown() -> None: + """Test confirm discovery aborts if called without discovery context.""" + flow = Itachip2irConfigFlow() + + result = await flow.async_step_confirm_discovery() + + assert result["type"] == "abort" + assert result["reason"] == "unknown" + + +async def test_confirm_discovery_cannot_identify_shows_form_error( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test discovery confirmation handles CannotIdentify errors.""" + _patch_client(monkeypatch) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DISCOVERY}, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + "unique_id": UNIQUE_ID, + "model": "iTachIP2IR", + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "confirm_discovery" + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.config_flow._validate_discovered_input", + AsyncMock(side_effect=CannotIdentify), + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "confirm_discovery" + assert result["errors"] == {"base": "cannot_identify"} diff --git a/tests/components/itachip2ir/test_diagnostics.py b/tests/components/itachip2ir/test_diagnostics.py new file mode 100644 index 00000000000000..e86ecc6692ae4b --- /dev/null +++ b/tests/components/itachip2ir/test_diagnostics.py @@ -0,0 +1,266 @@ +"""Tests for iTach IP2IR diagnostics.""" + +from typing import cast +from unittest.mock import AsyncMock + +from pyitach import ItachClient + +from homeassistant.components.itachip2ir import ItachRuntimeData +from homeassistant.components.itachip2ir.const import DOMAIN +from homeassistant.components.itachip2ir.diagnostics import ( + _extract_client, + async_get_config_entry_diagnostics, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +HOST = "192.168.1.211" +PORT = 4998 +UNIQUE_ID = "GlobalCache_000C1E123456" + + +class FakeClient: + """Fake iTach client for diagnostics tests.""" + + def __init__( + self, + *, + version: str = "710-1000-23", + version_error: Exception | None = None, + ) -> None: + """Initialize fake client.""" + self.version = version + self.version_error = version_error + self.async_get_version = AsyncMock(side_effect=self._async_get_version) + + async def _async_get_version(self, module: int) -> str: + """Return fake firmware version.""" + if self.version_error is not None: + raise self.version_error + + return self.version + + +class RuntimeWithInvalidClient: + """Runtime wrapper with an invalid client object.""" + + client = object() + + +def _make_entry( + *, + client: FakeClient | None = None, + data: dict | None = None, + options: dict | None = None, +) -> MockConfigEntry: + """Create a mock config entry with runtime data.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=UNIQUE_ID, + data=data + or { + "host": HOST, + "port": PORT, + "ir_module": 1, + "ir_ports": 3, + "ir_enabled_ports": [1, 3], + "ir_connector_modes": { + "1": "IR", + "2": "SENSOR", + "3": "IR_BLASTER", + }, + }, + options=options or {}, + title="iTach IP2IR", + ) + + entry.runtime_data = ItachRuntimeData( + host=HOST, + port=PORT, + device_id=UNIQUE_ID, + ir_module=1, + ir_ports=3, + ir_enabled_ports=[1, 3], + ir_connector_modes={ + "1": "IR", + "2": "SENSOR", + "3": "IR_BLASTER", + }, + client=cast(ItachClient, client or FakeClient()), + ) + + return entry + + +def test_extract_client_returns_none_for_invalid_client() -> None: + """Test invalid runtime client is ignored.""" + assert _extract_client(RuntimeWithInvalidClient()) is None + + +async def test_diagnostics_returns_redacted_entry_and_device_data( + hass: HomeAssistant, +) -> None: + """Test diagnostics returns useful redacted entry and device data.""" + client = FakeClient(version="710-1000-23") + entry = _make_entry(client=client) + entry.add_to_hass(hass) + + diagnostics = await async_get_config_entry_diagnostics(hass, entry) + + assert diagnostics == { + "entry": { + "title": "iTach IP2IR", + "domain": DOMAIN, + "data": { + "host": HOST, + "port": PORT, + "ir_module": 1, + "ir_ports": 3, + "ir_enabled_ports": [1, 3], + "ir_connector_modes": { + "1": "IR", + "2": "SENSOR", + "3": "IR_BLASTER", + }, + }, + "options": {}, + "unique_id": "**REDACTED**", + }, + "device": { + "host": HOST, + "port": PORT, + "device_id": "**REDACTED**", + "ir_module": 1, + "ir_ports": 3, + "ir_enabled_ports": [1, 3], + "ir_connector_modes": { + "1": "IR", + "2": "SENSOR", + "3": "IR_BLASTER", + }, + "firmware_version": "710-1000-23", + "firmware_error": None, + }, + } + + client.async_get_version.assert_awaited_once_with(1) + + +async def test_diagnostics_redacts_nested_sensitive_values(hass: HomeAssistant) -> None: + """Test diagnostics redacts sensitive fields in nested data and options.""" + entry = _make_entry( + data={ + "host": HOST, + "port": PORT, + "device_id": UNIQUE_ID, + "uuid": UNIQUE_ID, + "unique_id": UNIQUE_ID, + }, + options={ + "device_id": UNIQUE_ID, + "uuid": UNIQUE_ID, + "unique_id": UNIQUE_ID, + }, + ) + entry.add_to_hass(hass) + + diagnostics = await async_get_config_entry_diagnostics(hass, entry) + + assert diagnostics["entry"]["unique_id"] == "**REDACTED**" + assert diagnostics["device"]["device_id"] == "**REDACTED**" + + assert diagnostics["entry"]["data"]["device_id"] == "**REDACTED**" + assert diagnostics["entry"]["data"]["uuid"] == "**REDACTED**" + assert diagnostics["entry"]["data"]["unique_id"] == "**REDACTED**" + + assert diagnostics["entry"]["options"]["device_id"] == "**REDACTED**" + assert diagnostics["entry"]["options"]["uuid"] == "**REDACTED**" + assert diagnostics["entry"]["options"]["unique_id"] == "**REDACTED**" + + +async def test_diagnostics_handles_firmware_version_error(hass: HomeAssistant) -> None: + """Test diagnostics handles firmware lookup failures safely.""" + client = FakeClient(version_error=RuntimeError("device offline")) + entry = _make_entry(client=client) + entry.add_to_hass(hass) + + diagnostics = await async_get_config_entry_diagnostics(hass, entry) + + assert diagnostics["device"]["firmware_version"] is None + assert diagnostics["device"]["firmware_error"] == "device offline" + assert diagnostics["device"]["device_id"] == "**REDACTED**" + + client.async_get_version.assert_awaited_once_with(1) + + +async def test_diagnostics_includes_options_host_and_port(hass: HomeAssistant) -> None: + """Test diagnostics includes current options.""" + entry = _make_entry( + options={ + "host": "192.168.1.250", + "port": 5998, + }, + ) + entry.add_to_hass(hass) + + diagnostics = await async_get_config_entry_diagnostics(hass, entry) + + assert diagnostics["entry"]["options"] == { + "host": "192.168.1.250", + "port": 5998, + } + + +async def test_diagnostics_handles_missing_runtime_data(hass: HomeAssistant) -> None: + """Test diagnostics fall back to config entry data without runtime data.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=UNIQUE_ID, + data={ + "host": HOST, + "port": PORT, + "ir_module": 1, + "ir_ports": 3, + "ir_enabled_ports": [1, 3], + "ir_connector_modes": {"1": "IR", "3": "IR_BLASTER"}, + }, + options={}, + title="iTach IP2IR", + ) + entry.add_to_hass(hass) + + diagnostics = await async_get_config_entry_diagnostics(hass, entry) + + assert diagnostics["device"] == { + "host": HOST, + "port": PORT, + "device_id": "**REDACTED**", + "ir_module": 1, + "ir_ports": 3, + "ir_enabled_ports": [1, 3], + "ir_connector_modes": {"1": "IR", "3": "IR_BLASTER"}, + "firmware_version": None, + "firmware_error": None, + } + + +async def test_diagnostics_accepts_runtime_data_client_directly( + hass: HomeAssistant, +) -> None: + """Test diagnostics supports tests or callers storing the client directly.""" + client = FakeClient(version="710-2000-99") + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=UNIQUE_ID, + data={"host": HOST, "port": PORT, "ir_module": 2}, + options={}, + title="iTach IP2IR", + ) + entry.runtime_data = client + entry.add_to_hass(hass) + + diagnostics = await async_get_config_entry_diagnostics(hass, entry) + + assert diagnostics["device"]["firmware_version"] == "710-2000-99" + client.async_get_version.assert_awaited_once_with(2) diff --git a/tests/components/itachip2ir/test_discovery.py b/tests/components/itachip2ir/test_discovery.py new file mode 100644 index 00000000000000..954db722b8bbb9 --- /dev/null +++ b/tests/components/itachip2ir/test_discovery.py @@ -0,0 +1,656 @@ +"""Tests for iTach IP2IR discovery helpers.""" + +from unittest.mock import AsyncMock, MagicMock + +from pyitach import ItachDiscoveryBeacon +import pytest + +from homeassistant.components.itachip2ir.const import DOMAIN +from homeassistant.components.itachip2ir.discovery import ( + FLOW_THROTTLE_SECONDS, + ItachDiscovery, + async_discover_once, + async_wait_for_device_id, +) +from homeassistant.config_entries import SOURCE_DISCOVERY +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +HOST = "192.168.1.211" +OTHER_HOST = "192.168.1.212" +NEW_HOST = "192.168.1.250" +UNIQUE_ID = "GlobalCache_000C1E123456" + + +async def test_async_discover_once_returns_none_when_no_beacon( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test HA wrapper returns None when hardware discovery finds no beacon.""" + + async def fake_discover_once(timeout: float) -> None: + return None + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.discovery._async_discover_once", + fake_discover_once, + ) + + assert await async_discover_once(timeout=1.0) is None + + +async def test_async_discover_once_filters_non_ip2ir( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test HA wrapper filters non-IP2IR Global Caché beacons.""" + + async def fake_discover_once(timeout: float) -> ItachDiscoveryBeacon: + return ItachDiscoveryBeacon( + host=HOST, + uuid=UNIQUE_ID, + model="iTachIP2SL", + ) + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.discovery._async_discover_once", + fake_discover_once, + ) + + assert await async_discover_once(timeout=1.0) is None + + +async def test_async_discover_once_returns_ip2ir( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test HA wrapper returns discovered IP2IR data.""" + + async def fake_discover_once(timeout: float) -> ItachDiscoveryBeacon: + return ItachDiscoveryBeacon( + host=HOST, + uuid=UNIQUE_ID, + model="iTachIP2IR", + ) + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.discovery._async_discover_once", + fake_discover_once, + ) + + assert await async_discover_once(timeout=1.0) == { + "host": HOST, + "uuid": UNIQUE_ID, + "model": "iTachIP2IR", + } + + +async def test_async_wait_for_device_id_returns_matching_uuid( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test waiting for a matching host returns the discovered UUID.""" + + async def fake_discover_once(timeout: float) -> dict[str, str]: + return { + "host": HOST, + "uuid": UNIQUE_ID, + "model": "iTachIP2IR", + } + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.discovery.async_discover_once", + fake_discover_once, + ) + + assert await async_wait_for_device_id(HOST, timeout=10.0) == UNIQUE_ID + + +async def test_async_wait_for_device_id_returns_none_for_no_result( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test waiting for device ID returns None when discovery finds nothing.""" + + async def fake_discover_once(timeout: float) -> None: + return None + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.discovery.async_discover_once", + fake_discover_once, + ) + + assert await async_wait_for_device_id(HOST) is None + + +async def test_async_wait_for_device_id_returns_none_for_host_mismatch( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test waiting for device ID ignores beacons from another host.""" + + async def fake_discover_once(timeout: float) -> dict[str, str]: + return { + "host": OTHER_HOST, + "uuid": UNIQUE_ID, + "model": "iTachIP2IR", + } + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.discovery.async_discover_once", + fake_discover_once, + ) + + assert await async_wait_for_device_id(HOST) is None + + +async def test_async_wait_for_device_id_returns_none_for_blank_host() -> None: + """Test waiting for a blank host returns None without discovery.""" + assert await async_wait_for_device_id(" ") is None + + +async def test_async_wait_for_device_id_uses_discovery_cache( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test waiting for device ID uses the discovery cache first.""" + discovery = ItachDiscovery(hass) + discovery._known_devices[HOST] = UNIQUE_ID + discover_once = AsyncMock() + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.discovery.async_discover_once", + discover_once, + ) + + assert ( + await async_wait_for_device_id(HOST, timeout=10.0, discovery=discovery) + == UNIQUE_ID + ) + discover_once.assert_not_awaited() + + +def test_known_device_id_lookup(hass: HomeAssistant) -> None: + """Test known device ID lookup.""" + discovery = ItachDiscovery(hass) + + assert discovery.get_known_device_id(HOST) is None + + discovery._known_devices[HOST] = UNIQUE_ID + + assert discovery.get_known_device_id(HOST) == UNIQUE_ID + + +def test_known_device_id_blank_host_returns_none(hass: HomeAssistant) -> None: + """Test known device lookup handles blank host.""" + discovery = ItachDiscovery(hass) + + assert discovery.get_known_device_id(" ") is None + + +def test_is_already_configured_false(hass: HomeAssistant) -> None: + """Test _is_already_configured returns false when entry is unknown.""" + discovery = ItachDiscovery(hass) + + assert not discovery._is_already_configured(UNIQUE_ID) + + +def test_is_already_configured_true(hass: HomeAssistant) -> None: + """Test _is_already_configured returns true for configured unique ID.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=UNIQUE_ID, + data={"host": HOST, "port": 4998}, + title="iTach IP2IR", + ) + entry.add_to_hass(hass) + + discovery = ItachDiscovery(hass) + + assert discovery._is_already_configured(UNIQUE_ID) + + +def test_configured_entry_invalid_unique_id_returns_none(hass: HomeAssistant) -> None: + """Test configured entry lookup rejects invalid unique IDs.""" + discovery = ItachDiscovery(hass) + + assert discovery._configured_entry("not-a-valid-id") is None + + +def test_configured_device_host_update( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test discovery updates host for an already configured entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=UNIQUE_ID, + data={"host": "192.168.1.100", "port": 4998}, + title="iTach IP2IR (192.168.1.100)", + ) + entry.add_to_hass(hass) + + schedule_reload = MagicMock() + monkeypatch.setattr( + hass.config_entries, + "async_schedule_reload", + schedule_reload, + ) + + discovery = ItachDiscovery(hass) + discovery._update_configured_host(entry, HOST) + schedule_reload.assert_not_called() + + discovery._update_configured_host(entry, HOST) + + assert entry.data["host"] == HOST + assert entry.data["port"] == 4998 + assert entry.title == f"iTach IP2IR ({HOST})" + schedule_reload.assert_called_once_with(entry.entry_id) + + +def test_configured_device_host_update_preserves_custom_title( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test discovery host update preserves user/custom entry title.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=UNIQUE_ID, + data={"host": "192.168.1.100", "port": 4998}, + title="Living Room iTach", + ) + entry.add_to_hass(hass) + + schedule_reload = MagicMock() + monkeypatch.setattr( + hass.config_entries, + "async_schedule_reload", + schedule_reload, + ) + + discovery = ItachDiscovery(hass) + discovery._update_configured_host(entry, HOST) + schedule_reload.assert_not_called() + + discovery._update_configured_host(entry, HOST) + + assert entry.data["host"] == HOST + assert entry.title == "Living Room iTach" + schedule_reload.assert_called_once_with(entry.entry_id) + + +def test_configured_device_host_update_skips_options_override( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test discovery does not update host when options override host.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=UNIQUE_ID, + data={"host": "192.168.1.100", "port": 4998}, + options={"host": "192.168.1.250"}, + title="iTach IP2IR", + ) + entry.add_to_hass(hass) + + schedule_reload = MagicMock() + monkeypatch.setattr( + hass.config_entries, + "async_schedule_reload", + schedule_reload, + ) + + discovery = ItachDiscovery(hass) + discovery._update_configured_host(entry, HOST) + + assert entry.data["host"] == "192.168.1.100" + schedule_reload.assert_not_called() + + +def test_configured_device_host_update_skips_unchanged_host( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test discovery skips update when host has not changed.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=UNIQUE_ID, + data={"host": HOST, "port": 4998}, + title=f"iTach IP2IR ({HOST})", + ) + entry.add_to_hass(hass) + + schedule_reload = MagicMock() + monkeypatch.setattr( + hass.config_entries, + "async_schedule_reload", + schedule_reload, + ) + + discovery = ItachDiscovery(hass) + discovery._update_configured_host(entry, HOST) + + assert entry.data["host"] == HOST + schedule_reload.assert_not_called() + + +def test_configured_device_host_update_requires_same_host_confirmation( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test host update confirmation resets when discovered host changes.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=UNIQUE_ID, + data={"host": "192.168.1.100", "port": 4998}, + title="iTach IP2IR (192.168.1.100)", + ) + entry.add_to_hass(hass) + + schedule_reload = MagicMock() + monkeypatch.setattr( + hass.config_entries, + "async_schedule_reload", + schedule_reload, + ) + + discovery = ItachDiscovery(hass) + discovery._update_configured_host(entry, HOST) + discovery._update_configured_host(entry, OTHER_HOST) + + assert entry.data["host"] == "192.168.1.100" + schedule_reload.assert_not_called() + + discovery._update_configured_host(entry, OTHER_HOST) + + assert entry.data["host"] == OTHER_HOST + schedule_reload.assert_called_once_with(entry.entry_id) + + +def test_configured_device_host_update_blank_host_noops( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test discovery host update ignores blank discovered host.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=UNIQUE_ID, + data={"host": HOST, "port": 4998}, + title=f"iTach IP2IR ({HOST})", + ) + entry.add_to_hass(hass) + + schedule_reload = MagicMock() + monkeypatch.setattr( + hass.config_entries, + "async_schedule_reload", + schedule_reload, + ) + + discovery = ItachDiscovery(hass) + discovery._update_configured_host(entry, " ") + + assert entry.data["host"] == HOST + schedule_reload.assert_not_called() + + +def test_flow_throttle( + monkeypatch: pytest.MonkeyPatch, + hass: HomeAssistant, +) -> None: + """Test discovery flow throttling.""" + discovery = ItachDiscovery(hass) + + now = 1000.0 + monkeypatch.setattr( + "homeassistant.components.itachip2ir.discovery.time.monotonic", + lambda: now, + ) + + assert not discovery._is_flow_throttled(UNIQUE_ID) + + discovery._mark_flow_started(UNIQUE_ID) + + assert discovery._is_flow_throttled(UNIQUE_ID) + + +def test_flow_throttle_expires( + monkeypatch: pytest.MonkeyPatch, + hass: HomeAssistant, +) -> None: + """Test expired discovery flow throttle entries are pruned.""" + discovery = ItachDiscovery(hass) + + current_time = 1000.0 + + def fake_monotonic() -> float: + return current_time + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.discovery.time.monotonic", + fake_monotonic, + ) + + discovery._mark_flow_started(UNIQUE_ID) + assert discovery._is_flow_throttled(UNIQUE_ID) + + current_time = 1000.0 + FLOW_THROTTLE_SECONDS + 1 + + assert not discovery._is_flow_throttled(UNIQUE_ID) + assert UNIQUE_ID not in discovery._recent_flows + + +async def test_async_start_listener_failure( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test discovery start handles listener start failure.""" + listener = MagicMock() + listener.async_start = AsyncMock(return_value=False) + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.discovery.ItachDiscoveryListener", + MagicMock(return_value=listener), + ) + + discovery = ItachDiscovery(hass) + await discovery.async_start() + + assert discovery._listener is None + + +async def test_async_start_success_and_idempotent( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test discovery start creates one listener and is idempotent.""" + listener = MagicMock() + listener.async_start = AsyncMock(return_value=True) + + listener_factory = MagicMock(return_value=listener) + monkeypatch.setattr( + "homeassistant.components.itachip2ir.discovery.ItachDiscoveryListener", + listener_factory, + ) + + discovery = ItachDiscovery(hass) + + await discovery.async_start() + await discovery.async_start() + + assert discovery._listener is listener + listener_factory.assert_called_once() + listener.async_start.assert_awaited_once() + + +async def test_async_stop_cleanup(hass: HomeAssistant) -> None: + """Test discovery stop stops listener and clears caches.""" + listener = MagicMock() + listener.async_stop = AsyncMock() + discovery = ItachDiscovery(hass) + discovery._listener = listener + discovery._known_devices[HOST] = UNIQUE_ID + discovery._recent_flows[UNIQUE_ID] = 1000.0 + discovery._pending_host_updates["entry-id"] = {"host": HOST, "count": 1} + + await discovery.async_stop() + + assert discovery._listener is None + assert discovery._known_devices == {} + assert discovery._recent_flows == {} + assert discovery._pending_host_updates == {} + listener.async_stop.assert_awaited_once() + + +async def test_handle_beacon_triggers_flow(monkeypatch: pytest.MonkeyPatch) -> None: + """Test handler starts a discovery flow for a valid beacon.""" + hass = MagicMock() + async_init = AsyncMock(return_value={"type": "form"}) + monkeypatch.setattr(hass.config_entries.flow, "async_init", async_init) + monkeypatch.setattr( + hass.config_entries, + "async_entries", + MagicMock(return_value=[]), + ) + + discovery = ItachDiscovery(hass) + + await discovery._async_handle_beacon( + ItachDiscoveryBeacon(host=HOST, uuid=UNIQUE_ID, model="iTachIP2IR") + ) + + assert discovery.get_known_device_id(HOST) == UNIQUE_ID + async_init.assert_awaited_once_with( + DOMAIN, + context={"source": SOURCE_DISCOVERY}, + data={ + "host": HOST, + "port": 4998, + "unique_id": UNIQUE_ID, + "model": "iTachIP2IR", + }, + ) + + +async def test_handle_beacon_ignores_missing_uuid( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test handler ignores beacons missing UUID.""" + hass = MagicMock() + async_init = AsyncMock() + monkeypatch.setattr(hass.config_entries.flow, "async_init", async_init) + + discovery = ItachDiscovery(hass) + + await discovery._async_handle_beacon( + ItachDiscoveryBeacon(host=HOST, uuid="not-a-device-id", model="iTachIP2IR") + ) + + async_init.assert_not_awaited() + + +async def test_handle_beacon_ignores_non_ip2ir( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test handler ignores non-IP2IR Global Caché devices.""" + hass = MagicMock() + async_init = AsyncMock() + monkeypatch.setattr(hass.config_entries.flow, "async_init", async_init) + + discovery = ItachDiscovery(hass) + + await discovery._async_handle_beacon( + ItachDiscoveryBeacon(host=HOST, uuid=UNIQUE_ID, model="iTachIP2SL") + ) + + async_init.assert_not_awaited() + + +async def test_handle_beacon_ignores_already_configured_and_updates_host( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test handler updates host and skips flow for configured device.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=UNIQUE_ID, + data={"host": HOST, "port": 4998}, + title=f"iTach IP2IR ({HOST})", + ) + + hass = MagicMock() + async_init = AsyncMock() + update_entry = MagicMock() + schedule_reload = MagicMock() + + monkeypatch.setattr(hass.config_entries.flow, "async_init", async_init) + monkeypatch.setattr( + hass.config_entries, + "async_entries", + MagicMock(return_value=[entry]), + ) + monkeypatch.setattr(hass.config_entries, "async_update_entry", update_entry) + monkeypatch.setattr(hass.config_entries, "async_schedule_reload", schedule_reload) + + discovery = ItachDiscovery(hass) + + await discovery._async_handle_beacon( + ItachDiscoveryBeacon(host=NEW_HOST, uuid=UNIQUE_ID, model="iTachIP2IR") + ) + update_entry.assert_not_called() + schedule_reload.assert_not_called() + + await discovery._async_handle_beacon( + ItachDiscoveryBeacon(host=NEW_HOST, uuid=UNIQUE_ID, model="iTachIP2IR") + ) + + async_init.assert_not_awaited() + update_entry.assert_called_once_with( + entry, + title=f"iTach IP2IR ({NEW_HOST})", + data={ + "host": NEW_HOST, + "port": 4998, + }, + ) + schedule_reload.assert_called_once_with(entry.entry_id) + + +async def test_handle_beacon_throttled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test handler skips recently started discovery flows.""" + hass = MagicMock() + async_init = AsyncMock() + monkeypatch.setattr(hass.config_entries.flow, "async_init", async_init) + monkeypatch.setattr( + hass.config_entries, + "async_entries", + MagicMock(return_value=[]), + ) + + discovery = ItachDiscovery(hass) + monkeypatch.setattr(discovery, "_is_flow_throttled", lambda unique_id: True) + + await discovery._async_handle_beacon( + ItachDiscoveryBeacon(host=HOST, uuid=UNIQUE_ID, model="iTachIP2IR") + ) + + async_init.assert_not_awaited() + + +async def test_handle_beacon_flow_start_exception_is_handled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test handler handles discovery flow startup exceptions.""" + hass = MagicMock() + async_init = AsyncMock(side_effect=RuntimeError("boom")) + monkeypatch.setattr(hass.config_entries.flow, "async_init", async_init) + monkeypatch.setattr( + hass.config_entries, + "async_entries", + MagicMock(return_value=[]), + ) + + discovery = ItachDiscovery(hass) + + await discovery._async_handle_beacon( + ItachDiscoveryBeacon(host=HOST, uuid=UNIQUE_ID, model="iTachIP2IR") + ) + + async_init.assert_awaited_once() diff --git a/tests/components/itachip2ir/test_import.py b/tests/components/itachip2ir/test_import.py new file mode 100644 index 00000000000000..5c06e61b343b08 --- /dev/null +++ b/tests/components/itachip2ir/test_import.py @@ -0,0 +1,9 @@ +"""Tests for importing the iTach IP2IR integration.""" + +from homeassistant.components.itachip2ir.config_flow import Itachip2irConfigFlow +from homeassistant.config_entries import ConfigFlow + + +def test_config_flow_loads() -> None: + """Ensure config flow class loads.""" + assert issubclass(Itachip2irConfigFlow, ConfigFlow) diff --git a/tests/components/itachip2ir/test_infrared.py b/tests/components/itachip2ir/test_infrared.py new file mode 100644 index 00000000000000..cedb1436b4616e --- /dev/null +++ b/tests/components/itachip2ir/test_infrared.py @@ -0,0 +1,261 @@ +"""Tests for itachip2ir infrared entities.""" + +from types import SimpleNamespace +from typing import Any, cast +from unittest.mock import MagicMock, patch + +from pyitach import ItachBusyError, ItachCommandError, ItachConnectionError +import pytest + +from homeassistant.components.infrared import InfraredCommand +from homeassistant.components.itachip2ir.infrared import ItachInfraredEntity +from homeassistant.exceptions import HomeAssistantError + +HOST = "192.168.1.211" +DEVICE_ID = "GlobalCache_000C1E123456" + + +class FakeClient: + """Fake iTach client.""" + + def __init__( + self, + *, + error: Exception | None = None, + ) -> None: + """Initialize fake client.""" + self.error = error + self.calls: list[dict[str, Any]] = [] + + async def async_send_ir( + self, + module: int, + connector: int, + carrier_frequency: int, + timings: list[int], + ) -> None: + """Record sent IR command or raise configured error.""" + if self.error is not None: + raise self.error + + self.calls.append( + { + "module": module, + "connector": connector, + "carrier_frequency": carrier_frequency, + "timings": timings, + } + ) + + +class FakeCommand: + """Fake Home Assistant InfraredCommand.""" + + def __init__( + self, + *, + modulation: int | str = 38_000, + timings: list[Any] | None = None, + ) -> None: + """Initialize fake command.""" + self.modulation = modulation + self._timings = timings or [ + SimpleNamespace(high_us=9000, low_us=4500), + SimpleNamespace(high_us=562, low_us=40_000), + ] + + def get_raw_timings(self) -> list[Any]: + """Return fake raw timings.""" + return self._timings + + +def _entity( + *, + client: FakeClient | None = None, + mode: str = "IR", + port: int = 1, +) -> ItachInfraredEntity: + """Create an iTach infrared entity.""" + entity = ItachInfraredEntity( + host=HOST, + device_id=DEVICE_ID, + ir_module=1, + ir_port=port, + mode=mode, + client=client or FakeClient(), # type: ignore[arg-type] + ) + entity.hass = MagicMock() + return entity + + +@pytest.mark.asyncio +async def test_infrared_converts_timings() -> None: + """Test raw timings are converted to Global Caché cycles.""" + client = FakeClient() + entity = _entity(client=client) + + await entity.async_send_command(cast(InfraredCommand, FakeCommand())) + + assert client.calls == [ + { + "module": 1, + "connector": 1, + "carrier_frequency": 38_000, + "timings": [342, 171, 21, 1520], + } + ] + + +@pytest.mark.asyncio +async def test_infrared_rejects_non_positive_timings() -> None: + """Test zero and negative timings are rejected as invalid commands.""" + client = FakeClient() + entity = _entity(client=client) + + command = FakeCommand( + timings=[ + SimpleNamespace(high_us=1000, low_us=1000), + SimpleNamespace(high_us=0, low_us=-5), + ] + ) + + with pytest.raises(HomeAssistantError) as exc: + await entity.async_send_command(cast(InfraredCommand, command)) + + assert exc.value.translation_domain == "itachip2ir" + assert exc.value.translation_key == "itach_invalid_command" + assert exc.value.translation_placeholders is not None + assert "timing durations" in exc.value.translation_placeholders["error"] + assert client.calls == [] + + +def test_ir_port_entity_translation_and_unique_id() -> None: + """Test normal IR port uses translated naming metadata.""" + entity = _entity(mode="IR", port=1) + + assert entity.translation_key == "ir_port" + assert entity.translation_placeholders == {"port": "1"} + assert entity.unique_id == f"{DEVICE_ID}_port_1" + + +def test_ir_blaster_entity_translation_and_unique_id() -> None: + """Test IR blaster port uses translated naming metadata.""" + entity = _entity(mode="IR_BLASTER", port=3) + + assert entity.translation_key == "ir_blaster_port" + assert entity.translation_placeholders == {"port": "3"} + assert entity.unique_id == f"{DEVICE_ID}_port_3" + + +def test_device_info() -> None: + """Test device registry information.""" + entity = _entity() + + assert entity.device_info["identifiers"] == {("itachip2ir", DEVICE_ID)} + assert entity.device_info["name"] == f"iTach IP2IR ({HOST})" + assert entity.device_info["manufacturer"] == "Global Caché" + assert entity.device_info["model"] == "iTach IP2IR" + assert entity.device_info["configuration_url"] == f"http://{HOST}" + + +@pytest.mark.asyncio +async def test_send_command_busy_error_raises_translated_error() -> None: + """Test busy iTach error is translated.""" + entity = _entity(client=FakeClient(error=ItachBusyError("busy"))) + + with pytest.raises(HomeAssistantError) as exc: + await entity.async_send_command(cast(InfraredCommand, FakeCommand())) + + assert exc.value.translation_domain == "itachip2ir" + assert exc.value.translation_key == "itach_busy" + + +@pytest.mark.asyncio +async def test_send_command_rejected_error_raises_translated_error() -> None: + """Test rejected command error is translated.""" + entity = _entity(client=FakeClient(error=ItachCommandError("bad command"))) + + with pytest.raises(HomeAssistantError) as exc: + await entity.async_send_command(cast(InfraredCommand, FakeCommand())) + + assert exc.value.translation_domain == "itachip2ir" + assert exc.value.translation_key == "itach_rejected_command" + assert exc.value.translation_placeholders == {"error": "bad command"} + + +@pytest.mark.asyncio +async def test_send_command_connection_error_marks_unavailable_once() -> None: + """Test connection failure marks entity unavailable and writes state once.""" + entity = _entity(client=FakeClient(error=ItachConnectionError("offline"))) + + assert entity.available + + with patch.object(entity, "async_write_ha_state") as write_state: + with pytest.raises(HomeAssistantError) as exc: + await entity.async_send_command(cast(InfraredCommand, FakeCommand())) + + assert exc.value.translation_domain == "itachip2ir" + assert exc.value.translation_key == "itach_connection_failed" + assert exc.value.translation_placeholders == {"error": "offline"} + assert not entity.available + write_state.assert_called_once() + + with pytest.raises(HomeAssistantError): + await entity.async_send_command(cast(InfraredCommand, FakeCommand())) + + write_state.assert_called_once() + + +@pytest.mark.asyncio +async def test_send_command_success_after_unavailable_marks_available() -> None: + """Test successful send after failure marks entity available again.""" + client = FakeClient() + entity = _entity(client=client) + entity._attr_available = False + + with patch.object(entity, "async_write_ha_state") as write_state: + await entity.async_send_command(cast(InfraredCommand, FakeCommand())) + + assert entity.available + write_state.assert_called_once() + assert client.calls + + +def test_command_to_gc_timings_rejects_empty_raw_timings() -> None: + """Test raw timing conversion rejects commands without raw timings.""" + entity = _entity() + command = MagicMock() + command.get_raw_timings.return_value = [] + + with pytest.raises( + ValueError, + match="IR command contains no usable raw timings", + ): + entity._command_to_gc_timings(command, 38_000) + + +def test_device_connections_invalid_device_id_returns_empty() -> None: + """Test invalid device IDs produce no network MAC connections.""" + entity = _entity() + entity._device_id = "invalid" + + assert entity.device_info["connections"] == set() + + +@pytest.mark.asyncio +async def test_infrared_rejects_non_positive_carrier_frequency() -> None: + """Test invalid carrier frequency is rejected.""" + client = FakeClient() + entity = _entity(client=client) + + command = FakeCommand(modulation=0) + + with pytest.raises(HomeAssistantError) as exc: + await entity.async_send_command(cast(InfraredCommand, command)) + + assert exc.value.translation_domain == "itachip2ir" + assert exc.value.translation_key == "itach_invalid_command" + assert exc.value.translation_placeholders == { + "error": "Carrier frequency must be greater than zero" + } + assert client.calls == [] diff --git a/tests/components/itachip2ir/test_init.py b/tests/components/itachip2ir/test_init.py new file mode 100644 index 00000000000000..f2e81996ccc24f --- /dev/null +++ b/tests/components/itachip2ir/test_init.py @@ -0,0 +1,507 @@ +"""Tests for itachip2ir integration setup and unload.""" + +# pylint: disable=home-assistant-tests-direct-async-setup,home-assistant-tests-direct-async-setup-entry + +from typing import cast +from unittest.mock import AsyncMock, MagicMock + +from pyitach import ItachConnectionError, ItachError +import pytest + +from homeassistant.components.itachip2ir import ( + ItachRuntimeData, + async_reload_entry, + async_setup, + async_setup_entry, + async_unload_entry, +) +from homeassistant.components.itachip2ir.const import DISCOVERY, DOMAIN +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from tests.common import MockConfigEntry + +HOST = "192.168.1.211" +PORT = 4998 +UNIQUE_ID = "GlobalCache_000C1E123456" + + +class FakeDiscovery: + """Fake discovery listener.""" + + instances: list[FakeDiscovery] = [] + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize fake discovery listener.""" + self.hass = hass + self.async_start = AsyncMock() + self.async_stop = AsyncMock() + FakeDiscovery.instances.append(self) + + +class FakeClient: + """Fake iTach client.""" + + instances: list[FakeClient] = [] + + def __init__( + self, + host: str, + port: int, + *, + ir_module: int = 1, + ir_ports: int = 3, + connector_modes: dict[int, str] | None = None, + module_error: Exception | None = None, + modes_error: Exception | None = None, + ) -> None: + """Initialize fake iTach client.""" + self.host = host + self.port = port + self.ir_module = ir_module + self.ir_ports = ir_ports + self.connector_modes = ( + {1: "IR", 2: "SENSOR", 3: "IR_BLASTER"} + if connector_modes is None + else connector_modes + ) + self.module_error = module_error + self.modes_error = modes_error + self.close = AsyncMock() + FakeClient.instances.append(self) + + async def async_get_ir_module(self) -> tuple[int, int]: + """Return fake IR module information.""" + if self.module_error is not None: + raise self.module_error + return self.ir_module, self.ir_ports + + async def async_get_ir_connector_modes( + self, + module: int, + ports: int, + ) -> dict[int, str]: + """Return fake IR connector modes.""" + if self.modes_error is not None: + raise self.modes_error + return self.connector_modes + + +def _make_entry( + *, + data: dict | None = None, + options: dict | None = None, + unique_id: str | None = UNIQUE_ID, +) -> MockConfigEntry: + """Create a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=unique_id, + data=data or {"host": HOST, "port": PORT}, + options=options or {}, + title="iTach IP2IR", + ) + + +@pytest.fixture(autouse=True) +def reset_fakes() -> None: + """Reset fake instance tracking.""" + FakeDiscovery.instances.clear() + FakeClient.instances.clear() + + +async def test_async_setup_starts_discovery_once( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test async_setup starts discovery and does not start it twice.""" + hass.data["itachip2ir_disable_discovery"] = False + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.ItachDiscovery", + FakeDiscovery, + ) + + assert await async_setup(hass, {}) + assert await async_setup(hass, {}) + + assert len(FakeDiscovery.instances) == 1 + FakeDiscovery.instances[0].async_start.assert_awaited_once() + assert hass.data[DOMAIN][DISCOVERY] is FakeDiscovery.instances[0] + + +async def test_async_setup_does_not_start_discovery_when_disabled( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test async_setup does not start discovery when disabled for tests.""" + hass.data["itachip2ir_disable_discovery"] = True + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.ItachDiscovery", + FakeDiscovery, + ) + + assert await async_setup(hass, {}) + + assert FakeDiscovery.instances == [] + assert DISCOVERY not in hass.data[DOMAIN] + + +async def test_discovery_stops_on_home_assistant_stop( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test discovery listener is stopped on Home Assistant shutdown.""" + hass.data["itachip2ir_disable_discovery"] = False + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.ItachDiscovery", + FakeDiscovery, + ) + + assert await async_setup(hass, {}) + + discovery = FakeDiscovery.instances[0] + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + discovery.async_stop.assert_awaited_once() + assert DISCOVERY not in hass.data[DOMAIN] + + +async def test_async_setup_entry_success_creates_runtime_data( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test successful config entry setup creates runtime data.""" + entry = _make_entry() + entry.add_to_hass(hass) + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.ItachDiscovery", + FakeDiscovery, + ) + monkeypatch.setattr( + "homeassistant.components.itachip2ir.ItachClient", + FakeClient, + ) + + forward_setups = AsyncMock(return_value=True) + monkeypatch.setattr( + hass.config_entries, + "async_forward_entry_setups", + forward_setups, + ) + + assert await async_setup_entry(hass, entry) + + assert isinstance(entry.runtime_data, ItachRuntimeData) + assert entry.runtime_data.host == HOST + assert entry.runtime_data.port == PORT + assert entry.runtime_data.device_id == UNIQUE_ID + assert entry.runtime_data.ir_module == 1 + assert entry.runtime_data.ir_ports == 3 + assert entry.runtime_data.ir_enabled_ports == [1, 3] + assert entry.runtime_data.ir_connector_modes == { + "1": "IR", + "2": "SENSOR", + "3": "IR_BLASTER", + } + assert cast(object, entry.runtime_data.client) is FakeClient.instances[0] + + forward_setups.assert_awaited_once_with(entry, ["infrared"]) + + +async def test_async_setup_entry_uses_options_host_and_port( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test setup uses host and port from options when present.""" + entry = _make_entry( + data={"host": "192.168.1.100", "port": 4998}, + options={"host": HOST, "port": 5998}, + ) + entry.add_to_hass(hass) + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.ItachDiscovery", + FakeDiscovery, + ) + monkeypatch.setattr( + "homeassistant.components.itachip2ir.ItachClient", + FakeClient, + ) + monkeypatch.setattr( + hass.config_entries, + "async_forward_entry_setups", + AsyncMock(return_value=True), + ) + + assert await async_setup_entry(hass, entry) + + assert entry.runtime_data.host == HOST + assert entry.runtime_data.port == 5998 + assert FakeClient.instances[0].host == HOST + assert FakeClient.instances[0].port == 5998 + + +async def test_async_setup_entry_connection_error_raises_not_ready_and_closes( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test connection failure raises ConfigEntryNotReady and closes client.""" + entry = _make_entry() + entry.add_to_hass(hass) + + class ConnectionErrorClient(FakeClient): + def __init__(self, host: str, port: int) -> None: + super().__init__( + host, + port, + module_error=ItachConnectionError("cannot connect"), + ) + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.ItachDiscovery", + FakeDiscovery, + ) + monkeypatch.setattr( + "homeassistant.components.itachip2ir.ItachClient", + ConnectionErrorClient, + ) + + with pytest.raises(ConfigEntryNotReady, match="cannot_connect"): + await async_setup_entry(hass, entry) + + FakeClient.instances[0].close.assert_awaited_once() + + +async def test_async_setup_entry_ir_validation_error_raises_not_ready_and_closes( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test IR validation failure raises ConfigEntryNotReady and closes client.""" + entry = _make_entry() + entry.add_to_hass(hass) + + class ValidationErrorClient(FakeClient): + def __init__(self, host: str, port: int) -> None: + super().__init__( + host, + port, + modes_error=ItachError("bad get_IR response"), + ) + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.ItachDiscovery", + FakeDiscovery, + ) + monkeypatch.setattr( + "homeassistant.components.itachip2ir.ItachClient", + ValidationErrorClient, + ) + + with pytest.raises( + ConfigEntryNotReady, + match="invalid_config", + ): + await async_setup_entry(hass, entry) + + FakeClient.instances[0].close.assert_awaited_once() + + +async def test_async_setup_entry_get_ir_fallback_exposes_all_ports( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test setup falls back to all ports when get_IR returns no modes.""" + entry = _make_entry() + entry.add_to_hass(hass) + + class FallbackClient(FakeClient): + def __init__(self, host: str, port: int) -> None: + super().__init__( + host, + port, + ir_module=1, + ir_ports=3, + connector_modes={}, + ) + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.ItachDiscovery", + FakeDiscovery, + ) + monkeypatch.setattr( + "homeassistant.components.itachip2ir.ItachClient", + FallbackClient, + ) + monkeypatch.setattr( + hass.config_entries, + "async_forward_entry_setups", + AsyncMock(return_value=True), + ) + + assert await async_setup_entry(hass, entry) + + assert entry.runtime_data.ir_enabled_ports == [1, 2, 3] + assert entry.runtime_data.ir_connector_modes == { + "1": "UNKNOWN", + "2": "UNKNOWN", + "3": "UNKNOWN", + } + + +async def test_async_setup_entry_no_output_ports_raises_not_ready_and_closes( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test setup fails when get_IR returns modes but none are IR outputs.""" + entry = _make_entry() + entry.add_to_hass(hass) + + class NoOutputPortsClient(FakeClient): + def __init__(self, host: str, port: int) -> None: + super().__init__( + host, + port, + ir_module=1, + ir_ports=3, + connector_modes={1: "SENSOR", 2: "SENSOR", 3: "SENSOR"}, + ) + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.ItachDiscovery", + FakeDiscovery, + ) + monkeypatch.setattr( + "homeassistant.components.itachip2ir.ItachClient", + NoOutputPortsClient, + ) + + with pytest.raises( + ConfigEntryNotReady, + match="no_ir_ports", + ): + await async_setup_entry(hass, entry) + + FakeClient.instances[0].close.assert_awaited_once() + + +async def test_async_setup_entry_missing_unique_id_raises( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test setup fails if the config entry has no unique ID.""" + entry = _make_entry(unique_id=None) + entry.add_to_hass(hass) + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.ItachDiscovery", + FakeDiscovery, + ) + + with pytest.raises(ValueError, match="missing a unique_id"): + await async_setup_entry(hass, entry) + + +async def test_async_unload_entry_closes_client_and_keeps_discovery( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test unload closes client but does not stop discovery.""" + entry = _make_entry() + entry.runtime_data = MagicMock() + entry.runtime_data.client.close = AsyncMock() + + discovery = FakeDiscovery(hass) + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][DISCOVERY] = discovery + + unload_platforms = AsyncMock(return_value=True) + monkeypatch.setattr( + hass.config_entries, + "async_unload_platforms", + unload_platforms, + ) + + assert await async_unload_entry(hass, entry) + + unload_platforms.assert_awaited_once_with( + entry, + [Platform.INFRARED], + ) + entry.runtime_data.client.close.assert_awaited_once() + discovery.async_stop.assert_not_awaited() + assert hass.data[DOMAIN][DISCOVERY] is discovery + + +async def test_async_unload_entry_does_not_close_client_if_platform_unload_fails( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test client is not closed when platform unload fails.""" + entry = _make_entry() + entry.runtime_data = MagicMock() + entry.runtime_data.client.close = AsyncMock() + + monkeypatch.setattr( + hass.config_entries, + "async_unload_platforms", + AsyncMock(return_value=False), + ) + + assert not await async_unload_entry(hass, entry) + + entry.runtime_data.client.close.assert_not_awaited() + + +async def test_async_reload_entry_reloads_config_entry( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test reload delegates to the config entry manager.""" + entry = _make_entry() + async_reload = AsyncMock() + + monkeypatch.setattr(hass.config_entries, "async_reload", async_reload) + + await async_reload_entry(hass, entry) + + async_reload.assert_awaited_once_with(entry.entry_id) + + +async def test_async_setup_entry_starts_discovery_when_enabled( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test setup entry starts discovery when discovery is enabled.""" + hass.data["itachip2ir_disable_discovery"] = False + entry = _make_entry() + entry.add_to_hass(hass) + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.ItachDiscovery", + FakeDiscovery, + ) + monkeypatch.setattr( + "homeassistant.components.itachip2ir.ItachClient", + FakeClient, + ) + + forward_setups = AsyncMock(return_value=True) + monkeypatch.setattr( + hass.config_entries, + "async_forward_entry_setups", + forward_setups, + ) + + assert await async_setup_entry(hass, entry) + + assert len(FakeDiscovery.instances) == 1 + FakeDiscovery.instances[0].async_start.assert_awaited_once() + assert hass.data[DOMAIN][DISCOVERY] is FakeDiscovery.instances[0] + forward_setups.assert_awaited_once_with(entry, ["infrared"]) diff --git a/tests/components/itachip2ir/test_options_flow.py b/tests/components/itachip2ir/test_options_flow.py new file mode 100644 index 00000000000000..01f4feda1f9d83 --- /dev/null +++ b/tests/components/itachip2ir/test_options_flow.py @@ -0,0 +1,261 @@ +"""Tests for the iTach IP2IR options flow.""" + +from unittest.mock import AsyncMock, MagicMock + +from pyitach import ItachConnectionError, ItachError +import pytest + +from homeassistant.components.itachip2ir.const import DOMAIN +from homeassistant.components.itachip2ir.options_flow import ( + CONF_LAST_PORT_REFRESH, + SOURCE_REFRESH_INFRARED_PORTS, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +def _entry( + *, + options: dict[str, object] | None = None, +) -> MockConfigEntry: + """Create a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Living Room", + data={ + "host": "192.168.1.50", + "port": 4998, + "device_id": "000C1E123456", + }, + options=options or {}, + source="user", + entry_id="test-entry", + unique_id="000c1e123456", + ) + + +async def test_options_flow_menu(hass: HomeAssistant) -> None: + """Test the options menu.""" + entry = _entry() + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "init" + assert result["menu_options"] == [SOURCE_REFRESH_INFRARED_PORTS] + + +async def test_options_flow_refresh_form_from_source( + hass: HomeAssistant, +) -> None: + """Test refresh source opens the refresh confirmation form directly.""" + entry = _entry() + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init( + entry.entry_id, + context={"source": SOURCE_REFRESH_INFRARED_PORTS}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == SOURCE_REFRESH_INFRARED_PORTS + assert result["errors"] == {} + + +async def test_options_flow_refresh_success( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test successful infrared refresh.""" + entry = _entry() + entry.add_to_hass(hass) + + capability = MagicMock() + capability.enabled_ports = [1, 2, 3] + + client = MagicMock() + client.close = AsyncMock() + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.options_flow.ItachClient", + MagicMock(return_value=client), + ) + monkeypatch.setattr( + "homeassistant.components.itachip2ir.options_flow.async_get_ir_capability", + AsyncMock(return_value=capability), + ) + + result = await hass.config_entries.options.async_init( + entry.entry_id, + context={"source": SOURCE_REFRESH_INFRARED_PORTS}, + ) + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert CONF_LAST_PORT_REFRESH in result["data"] + client.close.assert_awaited_once() + + +async def test_options_flow_refresh_preserves_existing_options( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test refresh preserves unrelated options.""" + entry = _entry(options={"custom_option": "keep-me"}) + entry.add_to_hass(hass) + + capability = MagicMock() + capability.enabled_ports = [1] + + client = MagicMock() + client.close = AsyncMock() + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.options_flow.ItachClient", + MagicMock(return_value=client), + ) + monkeypatch.setattr( + "homeassistant.components.itachip2ir.options_flow.async_get_ir_capability", + AsyncMock(return_value=capability), + ) + + result = await hass.config_entries.options.async_init( + entry.entry_id, + context={"source": SOURCE_REFRESH_INFRARED_PORTS}, + ) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"]["custom_option"] == "keep-me" + assert CONF_LAST_PORT_REFRESH in result["data"] + client.close.assert_awaited_once() + + +async def test_options_flow_refresh_uses_host_and_port_from_options( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test refresh uses host and port overrides from options.""" + entry = _entry(options={"host": "192.168.1.60", "port": 1234}) + entry.add_to_hass(hass) + + capability = MagicMock() + capability.enabled_ports = [1] + + client = MagicMock() + client.close = AsyncMock() + client_factory = MagicMock(return_value=client) + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.options_flow.ItachClient", + client_factory, + ) + monkeypatch.setattr( + "homeassistant.components.itachip2ir.options_flow.async_get_ir_capability", + AsyncMock(return_value=capability), + ) + + result = await hass.config_entries.options.async_init( + entry.entry_id, + context={"source": SOURCE_REFRESH_INFRARED_PORTS}, + ) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + client_factory.assert_called_once_with("192.168.1.60", 1234) + client.close.assert_awaited_once() + + +async def test_options_flow_refresh_empty_enabled_ports_reports_no_ir_ports( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test refresh reports no_ir_ports when no ports are enabled.""" + entry = _entry() + entry.add_to_hass(hass) + + capability = MagicMock() + capability.enabled_ports = [] + + client = MagicMock() + client.close = AsyncMock() + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.options_flow.ItachClient", + MagicMock(return_value=client), + ) + monkeypatch.setattr( + "homeassistant.components.itachip2ir.options_flow.async_get_ir_capability", + AsyncMock(return_value=capability), + ) + + result = await hass.config_entries.options.async_init( + entry.entry_id, + context={"source": SOURCE_REFRESH_INFRARED_PORTS}, + ) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "no_ir_ports"} + client.close.assert_awaited_once() + + +@pytest.mark.parametrize( + ("error", "expected_error"), + [ + (ItachConnectionError("offline"), "cannot_connect"), + (ItachError("bad response"), "unknown"), + (ValueError("no ports"), "no_ir_ports"), + ], +) +async def test_options_flow_refresh_errors( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, + error: Exception, + expected_error: str, +) -> None: + """Test refresh validation errors are reported on the form.""" + entry = _entry() + entry.add_to_hass(hass) + + client = MagicMock() + client.close = AsyncMock() + + monkeypatch.setattr( + "homeassistant.components.itachip2ir.options_flow.ItachClient", + MagicMock(return_value=client), + ) + monkeypatch.setattr( + "homeassistant.components.itachip2ir.options_flow.async_get_ir_capability", + AsyncMock(side_effect=error), + ) + + result = await hass.config_entries.options.async_init( + entry.entry_id, + context={"source": SOURCE_REFRESH_INFRARED_PORTS}, + ) + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + client.close.assert_awaited_once() diff --git a/tests/components/itachip2ir/test_repairs.py b/tests/components/itachip2ir/test_repairs.py new file mode 100644 index 00000000000000..889650d1e4041c --- /dev/null +++ b/tests/components/itachip2ir/test_repairs.py @@ -0,0 +1,174 @@ +"""Tests for iTach IP2IR repairs.""" + +from homeassistant.components.itachip2ir.const import ( + DOMAIN, + ISSUE_CANNOT_CONNECT, + ISSUE_INVALID_CONFIG, + ISSUE_NO_IR_PORTS, +) +from homeassistant.components.itachip2ir.repairs import ( + ReconfigureRepairFlow, + async_create_fix_flow, + async_create_repair_issue, + async_delete_repair_issue, +) +from homeassistant.components.repairs import RepairsFlow +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + + +async def test_create_repair_issue(hass: HomeAssistant) -> None: + """Test creating a repair issue.""" + async_create_repair_issue( + hass, + ISSUE_CANNOT_CONNECT, + translation_key="cannot_connect", + placeholders={ + "entry_title": "iTach IP2IR", + "host": "192.168.1.211", + }, + ) + + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue(DOMAIN, ISSUE_CANNOT_CONNECT) + + assert issue is not None + assert issue.domain == DOMAIN + assert issue.issue_id == ISSUE_CANNOT_CONNECT + assert issue.issue_domain == DOMAIN + assert issue.is_fixable is False + assert issue.is_persistent is False + assert issue.severity is ir.IssueSeverity.ERROR + assert issue.translation_key == "cannot_connect" + assert issue.translation_placeholders == { + "entry_title": "iTach IP2IR", + "host": "192.168.1.211", + } + + +async def test_create_repair_issue_explicitly_fixable(hass: HomeAssistant) -> None: + """Test creating an explicitly fixable repair issue.""" + async_create_repair_issue( + hass, + ISSUE_INVALID_CONFIG, + translation_key="invalid_config", + is_fixable=True, + ) + + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue(DOMAIN, ISSUE_INVALID_CONFIG) + + assert issue is not None + assert issue.is_fixable is True + assert issue.translation_key == "invalid_config" + assert issue.translation_placeholders == { + "entry_title": "iTach IP2IR", + "error": "unknown", + } + + +async def test_delete_repair_issue(hass: HomeAssistant) -> None: + """Test deleting a repair issue.""" + async_create_repair_issue( + hass, + ISSUE_NO_IR_PORTS, + translation_key="no_ir_ports", + ) + + issue_registry = ir.async_get(hass) + assert issue_registry.async_get_issue(DOMAIN, ISSUE_NO_IR_PORTS) is not None + + async_delete_repair_issue(hass, ISSUE_NO_IR_PORTS) + + assert issue_registry.async_get_issue(DOMAIN, ISSUE_NO_IR_PORTS) is None + + +async def test_delete_missing_repair_issue(hass: HomeAssistant) -> None: + """Test deleting a missing repair issue is harmless.""" + issue_registry = ir.async_get(hass) + assert issue_registry.async_get_issue(DOMAIN, ISSUE_CANNOT_CONNECT) is None + + async_delete_repair_issue(hass, ISSUE_CANNOT_CONNECT) + + assert issue_registry.async_get_issue(DOMAIN, ISSUE_CANNOT_CONNECT) is None + + +async def test_async_create_fix_flow_returns_reconfigure_repair_flow( + hass: HomeAssistant, +) -> None: + """Test fix flow factory returns the expected flow.""" + flow = await async_create_fix_flow( + hass, + ISSUE_CANNOT_CONNECT, + { + "entry_title": "Living Room iTach", + "host": "192.168.1.211", + }, + ) + + assert isinstance(flow, ReconfigureRepairFlow) + assert isinstance(flow, RepairsFlow) + + +async def test_reconfigure_repair_flow_init_shows_confirm_form( + hass: HomeAssistant, +) -> None: + """Test repair flow init step shows the confirm form.""" + flow = ReconfigureRepairFlow( + ISSUE_CANNOT_CONNECT, + { + "entry_title": "Living Room iTach", + "host": "192.168.1.211", + }, + ) + flow.hass = hass + + result = await flow.async_step_init() + + assert result["type"] == "form" + assert result["step_id"] == "confirm" + assert result["description_placeholders"] == { + "entry_title": "Living Room iTach", + "host": "192.168.1.211", + } + + +async def test_reconfigure_repair_flow_confirm_uses_default_placeholders( + hass: HomeAssistant, +) -> None: + """Test repair flow confirm step falls back to default placeholders.""" + flow = ReconfigureRepairFlow(ISSUE_INVALID_CONFIG, None) + flow.hass = hass + + result = await flow.async_step_confirm() + + assert result["type"] == "form" + assert result["step_id"] == "confirm" + assert result["description_placeholders"] == { + "entry_title": "iTach IP2IR", + "host": "unknown", + } + + +async def test_reconfigure_repair_flow_submit_deletes_issue( + hass: HomeAssistant, +) -> None: + """Test submitting the repair flow deletes the repair issue.""" + async_create_repair_issue( + hass, + ISSUE_CANNOT_CONNECT, + translation_key="cannot_connect", + ) + + issue_registry = ir.async_get(hass) + assert issue_registry.async_get_issue(DOMAIN, ISSUE_CANNOT_CONNECT) is not None + + flow = ReconfigureRepairFlow(ISSUE_CANNOT_CONNECT, None) + flow.hass = hass + + result = await flow.async_step_confirm({}) + + assert result["type"] == "create_entry" + assert result["title"] == "" + assert result["data"] == {} + assert issue_registry.async_get_issue(DOMAIN, ISSUE_CANNOT_CONNECT) is None