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