diff --git a/CODEOWNERS b/CODEOWNERS index 821c3b99bd7718..6c0362bc7f4f02 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1659,6 +1659,8 @@ CLAUDE.md @home-assistant/core /tests/components/steamist/ @bdraco /homeassistant/components/stiebel_eltron/ @fucm @ThyMYthOS /tests/components/stiebel_eltron/ @fucm @ThyMYthOS +/homeassistant/components/stips_iru1/ @stips +/tests/components/stips_iru1/ @stips /homeassistant/components/stookwijzer/ @fwestenberg /tests/components/stookwijzer/ @fwestenberg /homeassistant/components/stream/ @hunterjm @uvjustin @allenporter diff --git a/homeassistant/components/stips_iru1/__init__.py b/homeassistant/components/stips_iru1/__init__.py new file mode 100644 index 00000000000000..12cf069953875b --- /dev/null +++ b/homeassistant/components/stips_iru1/__init__.py @@ -0,0 +1,72 @@ +"""STIPS IRU1 Home Assistant integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers import device_registry as dr + +from .catalog import normalize_device_mac +from .const import DOMAIN, PLATFORMS + + +@dataclass(slots=True) +class StipsIru1RuntimeData: + """Runtime data for the STIPS IRU1 integration.""" + + devices: list[dict[str, Any]] + + +type StipsIru1ConfigEntry = ConfigEntry[StipsIru1RuntimeData] + + +def _register_catalog_devices(hass: HomeAssistant, entry: StipsIru1ConfigEntry) -> None: + """Ensure every IR unit in the catalog has a device registry entry. + + Units with only protocol-AC remotes create no signal-based remote entities; without this, + Home Assistant would not list them under the integration. + """ + reg = dr.async_get(hass) + for device in entry.data.get("devices", []): + uid = device.get("uniqueName") + if not uid: + continue + name = device.get("name") or uid + sw = device.get("buildVersion") + kwargs: dict[str, Any] = { + "config_entry_id": entry.entry_id, + "identifiers": {(DOMAIN, str(uid))}, + "name": name, + "manufacturer": "STIPS", + "model": "IRU1", + "sw_version": str(sw) if sw is not None else None, + } + mac = normalize_device_mac(device) + if mac: + kwargs["connections"] = {(dr.CONNECTION_NETWORK_MAC, mac)} + area_name = device.get("areaName") + if area_name: + kwargs["suggested_area"] = str(area_name) + reg.async_get_or_create(**kwargs) + + +async def async_setup_entry(hass: HomeAssistant, entry: StipsIru1ConfigEntry) -> bool: + """Set up STIPS IRU1 from a config entry.""" + devices = entry.data.get("devices", []) + if not isinstance(devices, list): + raise ConfigEntryError("Invalid devices data in config entry") + + entry.runtime_data = StipsIru1RuntimeData(devices=devices) + + _register_catalog_devices(hass, entry) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: StipsIru1ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/stips_iru1/api.py b/homeassistant/components/stips_iru1/api.py new file mode 100644 index 00000000000000..9f3b898cf6697b --- /dev/null +++ b/homeassistant/components/stips_iru1/api.py @@ -0,0 +1,19 @@ +"""Compatibility wrapper for STIPS API client. + +Core-bound integration code should consume communication logic from the standalone +`stips_api_bridge` package for dependency transparency. +""" + +from stips_api_bridge.api import ( + StipsApiAuthError, + StipsApiClient, + StipsApiError, + StipsApiPermissionError, +) + +__all__ = [ + "StipsApiAuthError", + "StipsApiClient", + "StipsApiError", + "StipsApiPermissionError", +] diff --git a/homeassistant/components/stips_iru1/catalog.py b/homeassistant/components/stips_iru1/catalog.py new file mode 100644 index 00000000000000..7063dc3f9d5ee3 --- /dev/null +++ b/homeassistant/components/stips_iru1/catalog.py @@ -0,0 +1,27 @@ +"""Compatibility wrapper for STIPS catalog helpers.""" + +from stips_api_bridge.catalog import ( + async_enrich_remote_model, + async_fetch_catalog_devices, + iter_device_host_candidates, + iter_model_read_type_keys, + iter_model_read_type_keys_union, + model_has_ir_signals, + model_read_name_or_id, + normalize_device_ip, + normalize_device_mac, + normalize_device_online, +) + +__all__ = [ + "async_enrich_remote_model", + "async_fetch_catalog_devices", + "iter_device_host_candidates", + "iter_model_read_type_keys", + "iter_model_read_type_keys_union", + "model_has_ir_signals", + "model_read_name_or_id", + "normalize_device_ip", + "normalize_device_mac", + "normalize_device_online", +] diff --git a/homeassistant/components/stips_iru1/climate.py b/homeassistant/components/stips_iru1/climate.py new file mode 100644 index 00000000000000..8f53bffe12d694 --- /dev/null +++ b/homeassistant/components/stips_iru1/climate.py @@ -0,0 +1,811 @@ +"""Climate platform for STIPS IRU1 protocol AC remotes.""" + +from __future__ import annotations + +from typing import Any + +import aiohttp + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .catalog import ( + model_has_ir_signals, + normalize_device_ip, + normalize_device_mac, + normalize_device_online, +) +from .const import DOMAIN, is_learned_ac, is_protocol_ac +from .local_http import async_build_control_hosts + +_MODE_TO_HVAC: dict[int, HVACMode] = { + 0: HVACMode.AUTO, + 1: HVACMode.COOL, + 2: HVACMode.HEAT, + 3: HVACMode.DRY, + 4: HVACMode.FAN_ONLY, +} +_HVAC_TO_MODE: dict[HVACMode, int] = {v: k for k, v in _MODE_TO_HVAC.items()} + +_FAN_INT_TO_NAME: dict[int, str] = { + 0: "auto", + 1: "min", + 2: "low", + 3: "medium", + 4: "high", + 5: "max", +} +_FAN_NAME_TO_INT: dict[str, int] = {v: k for k, v in _FAN_INT_TO_NAME.items()} + + +def _safe_int(value: Any, default: int) -> int: + try: + return int(value) + except TypeError, ValueError: + return default + + +def _normalize_swing_mode(swing_v: int, swing_h: int) -> str: + if swing_v and swing_h: + return "both" + if swing_v: + return "vertical" + if swing_h: + return "horizontal" + return "off" + + +def _split_swing_mode(mode: str) -> tuple[int, int]: + m = (mode or "").strip().lower() + if m == "vertical": + return 1, 0 + if m == "horizontal": + return 0, 1 + if m == "both": + return 1, 1 + return 0, 0 + + +def _normalize_mode_label(value: Any) -> str: + s = str(value or "").strip().lower() + if s in {"fan", "fan_only", "fanonly"}: + return "fan" + return s + + +def _mode_to_hvac(mode: Any) -> HVACMode: + m = _normalize_mode_label(mode) + if m == "auto": + return HVACMode.AUTO + if m == "cool": + return HVACMode.COOL + if m == "heat": + return HVACMode.HEAT + if m == "dry": + return HVACMode.DRY + if m == "fan": + return HVACMode.FAN_ONLY + return HVACMode.COOL + + +def _fan_to_name(value: Any) -> str: + v = str(value or "").strip().lower() + if v in _FAN_NAME_TO_INT: + return v + if v.isdigit(): + return _FAN_INT_TO_NAME.get(int(v), "medium") + aliases = { + "med": "medium", + "mid": "medium", + "maximum": "max", + "minimum": "min", + } + return aliases.get(v, "medium") + + +def _extract_learned_ac_signals( + remote_snapshot: dict[str, Any], +) -> tuple[list[dict[str, Any]], str | None, str | None, int]: + model = remote_snapshot.get("model") or {} + frequency = _safe_int(model.get("frequency") or model.get("Frequency"), 38000) + out: list[dict[str, Any]] = [] + for raw in model.get("signals") or model.get("Signals") or []: + if not isinstance(raw, dict): + continue + signal = raw.get("signal") or raw.get("Signal") + if not signal or not str(signal).strip(): + continue + mode = _normalize_mode_label(raw.get("mode") or "") + temp: int | None = None + if raw.get("temperature") is not None: + try: + temp = int(str(raw.get("temperature"))) + except TypeError, ValueError: + temp = None + fan = _fan_to_name(raw.get("fanSpeed") or raw.get("fan") or "") + out.append( + { + "mode": mode, + "temp": temp, + "fan": fan, + "signal": str(signal), + } + ) + power_on = model.get("powerOnSignal") or model.get("PowerOnSignal") + power_off = model.get("powerOffSignal") or model.get("PowerOffSignal") + on_s = ( + str(power_on).strip() + if power_on is not None and str(power_on).strip() + else None + ) + off_s = ( + str(power_off).strip() + if power_off is not None and str(power_off).strip() + else None + ) + return out, on_s, off_s, frequency + + +def _pick_best_learned_signal( + entries: list[dict[str, Any]], + hvac_mode: HVACMode, + temp: int, + fan_mode: str, +) -> str | None: + wanted_mode = "fan" if hvac_mode == HVACMode.FAN_ONLY else hvac_mode.value + wanted_mode = _normalize_mode_label(wanted_mode) + wanted_fan = _fan_to_name(fan_mode) + + best: tuple[int, str] | None = None + for row in entries: + row_mode = _normalize_mode_label(row.get("mode")) + row_temp = row.get("temp") + row_fan = _fan_to_name(row.get("fan")) + if row_mode and row_mode != wanted_mode: + continue + score = 0 + if row_mode == wanted_mode: + score += 4 + if row_temp is not None: + if int(row_temp) != int(temp): + continue + score += 2 + if row_fan and row_fan != wanted_fan: + continue + if row_fan == wanted_fan: + score += 1 + sig = str(row.get("signal") or "").strip() + if not sig: + continue + if best is None or score > best[0]: + best = (score, sig) + return best[1] if best is not None else None + + +def _extract_initial_ac_state(remote: dict[str, Any]) -> dict[str, int]: + ac = remote.get("acStatus") or {} + modes = ac.get("modeStates") or {} + last_key = str(ac.get("lastModeName") or "cool").strip().lower() + chosen: dict[str, Any] | None = None + + if isinstance(modes, dict): + for key, state in modes.items(): + if str(key).strip().lower() == last_key and isinstance(state, dict): + chosen = state + break + if chosen is None: + for state in modes.values(): + if isinstance(state, dict): + chosen = state + break + + if chosen is None: + chosen = {} + + return { + "power": _safe_int(chosen.get("power"), 0), + "mode": _safe_int(chosen.get("mode"), 1), + "fan": _safe_int(chosen.get("fan"), 3), + "temp": _safe_int(chosen.get("temperature"), 22), + "swingV": _safe_int(chosen.get("swingV"), 0), + "swingH": _safe_int(chosen.get("swingH"), 0), + "light": _safe_int(chosen.get("light"), 1), + "beep": _safe_int(chosen.get("beep"), 1), + "econo": _safe_int(chosen.get("econo"), 0), + "filter": _safe_int(chosen.get("filter"), 0), + "turbo": _safe_int(chosen.get("turbo"), 0), + "quiet": _safe_int(chosen.get("quiet"), 0), + "clean": _safe_int(chosen.get("clean"), 0), + "sleep": _safe_int(chosen.get("sleep"), 0), + } + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Create climate entities for protocol AC and LearnedAc remotes.""" + entities: list[ClimateEntity] = [] + + for device in entry.data.get("devices", []): + device_unique_name = device.get("uniqueName") + if not device_unique_name: + continue + device_name = device.get("name") or device_unique_name + device_ip = normalize_device_ip(device) + device_mac = normalize_device_mac(device) + device_online = normalize_device_online(device) + remotes = device.get("remotes") or [] + + for idx, remote in enumerate(remotes): + remote_type = str(remote.get("type") or "") + model = remote.get("model") or {} + + remote_id = remote.get("id") + friendly = remote.get("friendlyName") or remote.get("type") or "AC" + rid = ( + str(remote_id) + if remote_id is not None + else f"{idx}_{str(friendly).strip().lower().replace(' ', '_')}" + ) + + if is_protocol_ac(remote_type): + if model.get("protocol") is None: + continue + entities.append( + StipsIruClimate( + hass=hass, + device_unique_name=str(device_unique_name), + device_name=str(device_name), + device_ip=device_ip, + device_mac=device_mac, + device_online=device_online, + remote_id=str(rid), + friendly_name=str(friendly), + remote_snapshot=dict(remote), + ) + ) + continue + + if is_learned_ac(remote_type) and model_has_ir_signals(model): + entities.append( + StipsIruLearnedAcClimate( + hass=hass, + device_unique_name=str(device_unique_name), + device_name=str(device_name), + device_ip=device_ip, + device_mac=device_mac, + device_online=device_online, + remote_id=str(rid), + friendly_name=str(friendly), + remote_snapshot=dict(remote), + ) + ) + + async_add_entities(entities) + + +class StipsIruClimate(ClimateEntity): + """Climate control for one STIPS protocol AC remote.""" + + _attr_should_poll = False + _attr_has_entity_name = True + _attr_icon = "mdi:air-conditioner" + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_target_temperature_step = 1 + _attr_min_temp = 16 + _attr_max_temp = 30 + _attr_hvac_modes = [ + HVACMode.OFF, + HVACMode.AUTO, + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.DRY, + HVACMode.FAN_ONLY, + ] + _attr_fan_modes = ["auto", "min", "low", "medium", "high", "max"] + _attr_swing_modes = ["off", "vertical", "horizontal", "both"] + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + ) + + def __init__( + self, + *, + hass: HomeAssistant, + device_unique_name: str, + device_name: str, + device_ip: str, + device_mac: str, + device_online: bool, + remote_id: str, + friendly_name: str, + remote_snapshot: dict[str, Any], + ) -> None: + """Initialize a protocol AC climate entity.""" + super().__init__() + self.hass = hass + self._device_unique_name = device_unique_name + self._device_name = device_name + self._device_ip = device_ip + self._device_ip_live = "" + self._device_mac = device_mac + self._device_online = device_online + self._remote_snapshot = remote_snapshot + self._proto = _safe_int( + (remote_snapshot.get("model") or {}).get("protocol"), -1 + ) + self._model = 0 + + safe_rid = "".join(c if c.isalnum() or c in "-_" else "_" for c in remote_id)[ + :80 + ] + self._attr_unique_id = f"{DOMAIN}_{device_unique_name}_climate_{safe_rid}" + self._attr_name = f"{friendly_name} Climate" + self._attr_available = True + + self._state = _extract_initial_ac_state(remote_snapshot) + + @property + def device_info(self) -> DeviceInfo | None: + """Return device registry metadata for this climate entity.""" + return DeviceInfo( + identifiers={(DOMAIN, self._device_unique_name)}, + name=self._device_name, + manufacturer="STIPS", + model="IRU1", + connections={(CONNECTION_NETWORK_MAC, self._device_mac)} + if self._device_mac + else set(), + configuration_url=f"http://{self._device_unique_name}/device_info", + ) + + @property + def available(self) -> bool: + """Protocol AC remotes are treated as available once loaded.""" + return True + + @property + def hvac_mode(self) -> HVACMode: + """Current HVAC mode derived from the last known AC state.""" + if _safe_int(self._state.get("power"), 0) == 0: + return HVACMode.OFF + return _MODE_TO_HVAC.get(_safe_int(self._state.get("mode"), 1), HVACMode.COOL) + + @property + def target_temperature(self) -> float: + """Current target temperature in Celsius.""" + return float(_safe_int(self._state.get("temp"), 22)) + + @property + def fan_mode(self) -> str: + """Current fan mode label.""" + return _FAN_INT_TO_NAME.get(_safe_int(self._state.get("fan"), 3), "medium") + + @property + def swing_mode(self) -> str: + """Current swing mode label.""" + return _normalize_swing_mode( + _safe_int(self._state.get("swingV"), 0), + _safe_int(self._state.get("swingH"), 0), + ) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Expose debugging metadata for the backing STIPS device.""" + return { + "device_unique_name": self._device_unique_name, + "unique_name": self._device_unique_name, + "device_online": self._device_online, + "remote_type": self._remote_snapshot.get("type"), + "protocol": self._proto, + "light": _safe_int(self._state.get("light"), 1), + "beep": _safe_int(self._state.get("beep"), 1), + "econo": _safe_int(self._state.get("econo"), 0), + "filter": _safe_int(self._state.get("filter"), 0), + "turbo": _safe_int(self._state.get("turbo"), 0), + "quiet": _safe_int(self._state.get("quiet"), 0), + "clean": _safe_int(self._state.get("clean"), 0), + "sleep": _safe_int(self._state.get("sleep"), 0), + } + + async def async_turn_on(self) -> None: + """Turn the AC on using the cached protocol state.""" + await self._send_update(power=1) + + async def async_turn_off(self) -> None: + """Turn the AC off using the cached protocol state.""" + await self._send_update(power=0) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Apply a new HVAC mode.""" + if hvac_mode == HVACMode.OFF: + await self._send_update(power=0) + return + mode = _HVAC_TO_MODE.get(hvac_mode) + if mode is None: + raise HomeAssistantError(f"Unsupported HVAC mode: {hvac_mode}") + await self._send_update(power=1, mode=mode) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Apply a new target temperature.""" + if ATTR_TEMPERATURE not in kwargs: + raise HomeAssistantError("Temperature is required") + requested = int(float(kwargs[ATTR_TEMPERATURE])) + requested = max(int(self.min_temp), min(int(self.max_temp), requested)) + await self._send_update(power=1, temp=requested) + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Apply a new fan mode.""" + key = (fan_mode or "").strip().lower() + if key not in _FAN_NAME_TO_INT: + raise HomeAssistantError(f"Unsupported fan mode: {fan_mode}") + await self._send_update(power=1, fan=_FAN_NAME_TO_INT[key]) + + async def async_set_swing_mode(self, swing_mode: str) -> None: + """Apply a new swing mode.""" + swing_v, swing_h = _split_swing_mode(swing_mode) + await self._send_update(power=1, swingV=swing_v, swingH=swing_h) + + async def _send_update(self, **overrides: int) -> None: + hosts, live_ip = await async_build_control_hosts( + self.hass, + device_unique_name=self._device_unique_name, + backend_ip=self._device_ip, + ) + if not hosts: + raise HomeAssistantError( + "Device host is missing; reload or reconfigure the STIPS IRU1 integration, " + "or update the device IP in the config entry if it has changed." + ) + if live_ip: + self._device_ip_live = live_ip + if self._proto < 0: + raise HomeAssistantError("AC protocol is missing in remote model") + + payload_state = dict(self._state) + payload_state.update(overrides) + + fields: dict[str, Any] = { + "type": self._proto, + "model": self._model, + "power": _safe_int(payload_state.get("power"), 0), + "mode": _safe_int(payload_state.get("mode"), 1), + "fan": _safe_int(payload_state.get("fan"), 3), + "temp": _safe_int(payload_state.get("temp"), 22), + "swingV": _safe_int(payload_state.get("swingV"), 0), + "swingH": _safe_int(payload_state.get("swingH"), 0), + "light": _safe_int(payload_state.get("light"), 1), + "beep": _safe_int(payload_state.get("beep"), 1), + "econo": _safe_int(payload_state.get("econo"), 0), + "filter": _safe_int(payload_state.get("filter"), 0), + "turbo": _safe_int(payload_state.get("turbo"), 0), + "quiet": _safe_int(payload_state.get("quiet"), 0), + "clean": _safe_int(payload_state.get("clean"), 0), + "sleep": _safe_int(payload_state.get("sleep"), 0), + } + + session = async_get_clientsession(self.hass) + auth = None + timeout = aiohttp.ClientTimeout( + total=3, connect=1.5, sock_connect=1.5, sock_read=2 + ) + last_error: Exception | None = None + sent_ok = False + for host in hosts: + url = f"http://{host}/local-ir/ac-command" + try: + async with session.post( + url, + data={k: str(v) for k, v in fields.items()}, + auth=auth, + timeout=timeout, + ) as response: + if response.status >= 400: + body = await response.text() + last_error = HomeAssistantError( + f"Local AC request failed ({response.status}) via {host}: {body[:160]}" + ) + continue + sent_ok = True + last_error = None + break + except (TimeoutError, aiohttp.ClientError) as err: + last_error = err + continue + if not sent_ok and isinstance(last_error, HomeAssistantError): + raise HomeAssistantError(f"{last_error} | hosts={', '.join(hosts)}") + if not sent_ok: + detail = str(last_error).strip() or type(last_error).__name__ + raise HomeAssistantError( + f"Cannot reach IR device locally (hosts: {', '.join(hosts)}): {detail}" + ) from last_error + + self._state = payload_state + self.async_write_ha_state() + + +class StipsIruLearnedAcClimate(ClimateEntity): + """Climate control for LearnedAc remotes using learned signal rows.""" + + _attr_should_poll = False + _attr_has_entity_name = True + _attr_icon = "mdi:air-conditioner" + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_target_temperature_step = 1 + _attr_hvac_modes = [ + HVACMode.OFF, + HVACMode.AUTO, + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.DRY, + HVACMode.FAN_ONLY, + ] + _attr_fan_modes = ["auto", "min", "low", "medium", "high", "max"] + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + ) + + def __init__( + self, + *, + hass: HomeAssistant, + device_unique_name: str, + device_name: str, + device_ip: str, + device_mac: str, + device_online: bool, + remote_id: str, + friendly_name: str, + remote_snapshot: dict[str, Any], + ) -> None: + """Initialize a learned-AC climate entity.""" + super().__init__() + self.hass = hass + self._device_unique_name = device_unique_name + self._device_name = device_name + self._device_ip = device_ip + self._device_ip_live = "" + self._device_mac = device_mac + self._device_online = device_online + self._remote_snapshot = remote_snapshot + self._remote_type = str(remote_snapshot.get("type") or "LearnedAc") + + ( + self._signals, + self._power_on_signal, + self._power_off_signal, + self._frequency, + ) = _extract_learned_ac_signals(remote_snapshot) + temps = [int(v["temp"]) for v in self._signals if v.get("temp") is not None] + self._attr_min_temp = min(temps) if temps else 16 + self._attr_max_temp = max(temps) if temps else 30 + + safe_rid = "".join(c if c.isalnum() or c in "-_" else "_" for c in remote_id)[ + :80 + ] + self._attr_unique_id = ( + f"{DOMAIN}_{device_unique_name}_climate_learned_ac_{safe_rid}" + ) + self._attr_name = f"{friendly_name} Climate" + self._attr_available = True + + modes = {_mode_to_hvac(v.get("mode")) for v in self._signals} + self._attr_hvac_modes = [ + HVACMode.OFF, + *[ + m + for m in ( + HVACMode.AUTO, + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.DRY, + HVACMode.FAN_ONLY, + ) + if m in modes + ], + ] + if len(self._attr_hvac_modes) == 1: + self._attr_hvac_modes = [HVACMode.OFF, HVACMode.COOL] + + fans = sorted({_fan_to_name(v.get("fan")) for v in self._signals}) + self._attr_fan_modes = [ + f for f in ("auto", "min", "low", "medium", "high", "max") if f in fans + ] or ["medium"] + + default_mode = next( + (m for m in self._attr_hvac_modes if m != HVACMode.OFF), HVACMode.COOL + ) + default_temp = int((self._attr_min_temp + self._attr_max_temp) / 2) + default_fan = self._attr_fan_modes[0] + self._state: dict[str, Any] = { + "power": 0, + "hvac_mode": default_mode, + "temp": default_temp, + "fan": default_fan, + } + + @property + def device_info(self) -> DeviceInfo | None: + """Return device registry metadata for this learned-AC entity.""" + return DeviceInfo( + identifiers={(DOMAIN, self._device_unique_name)}, + name=self._device_name, + manufacturer="STIPS", + model="IRU1", + connections={(CONNECTION_NETWORK_MAC, self._device_mac)} + if self._device_mac + else set(), + configuration_url=f"http://{self._device_unique_name}/device_info", + ) + + @property + def available(self) -> bool: + """Learned-AC remotes are treated as available once loaded.""" + return True + + @property + def hvac_mode(self) -> HVACMode: + """Current HVAC mode derived from the learned AC state.""" + if int(self._state.get("power", 0)) == 0: + return HVACMode.OFF + return self._state.get("hvac_mode", HVACMode.COOL) + + @property + def target_temperature(self) -> float: + """Current target temperature in Celsius.""" + return float(int(self._state.get("temp", self._attr_min_temp))) + + @property + def fan_mode(self) -> str: + """Current fan mode label.""" + return _fan_to_name(self._state.get("fan", "medium")) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Expose debugging metadata for the learned-AC remote.""" + return { + "device_unique_name": self._device_unique_name, + "unique_name": self._device_unique_name, + "device_online": self._device_online, + "remote_type": self._remote_type, + "learned_signal_count": len(self._signals), + } + + async def async_turn_on(self) -> None: + """Turn the remote on using the best matching learned signal.""" + if self.hvac_mode == HVACMode.OFF: + mode = next( + (m for m in self._attr_hvac_modes if m != HVACMode.OFF), HVACMode.COOL + ) + await self._send_state(power=1, hvac_mode=mode) + return + await self._send_state(power=1) + + async def async_turn_off(self) -> None: + """Turn the remote off using the learned power-off signal when available.""" + if self._power_off_signal: + await self._post_signal(self._power_off_signal) + self._state["power"] = 0 + self.async_write_ha_state() + return + await self._send_state(power=0) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Apply a new HVAC mode.""" + if hvac_mode == HVACMode.OFF: + await self.async_turn_off() + return + await self._send_state(power=1, hvac_mode=hvac_mode) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Apply a new target temperature.""" + if ATTR_TEMPERATURE not in kwargs: + raise HomeAssistantError("Temperature is required") + requested = int(float(kwargs[ATTR_TEMPERATURE])) + requested = max(int(self.min_temp), min(int(self.max_temp), requested)) + await self._send_state(power=1, temp=requested) + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Apply a new fan mode.""" + key = (fan_mode or "").strip().lower() + if key not in _FAN_NAME_TO_INT: + raise HomeAssistantError(f"Unsupported fan mode: {fan_mode}") + await self._send_state(power=1, fan=key) + + async def _send_state(self, **overrides: Any) -> None: + payload_state = dict(self._state) + payload_state.update(overrides) + if int(payload_state.get("power", 0)) == 0 and self._power_off_signal: + await self._post_signal(self._power_off_signal) + self._state = payload_state + self.async_write_ha_state() + return + + hvac_mode = payload_state.get("hvac_mode", HVACMode.COOL) + temp = int(payload_state.get("temp", self._attr_min_temp)) + fan = _fan_to_name(payload_state.get("fan", "medium")) + signal = _pick_best_learned_signal(self._signals, hvac_mode, temp, fan) + if ( + not signal + and self._power_on_signal + and int(payload_state.get("power", 1)) == 1 + ): + await self._post_signal(self._power_on_signal) + self._state = payload_state + self.async_write_ha_state() + return + if not signal: + raise HomeAssistantError( + "No learned AC signal matches requested mode/temp/fan for this remote" + ) + + await self._post_signal(signal) + self._state = payload_state + self.async_write_ha_state() + + async def _post_signal(self, signal: str) -> None: + hosts, live_ip = await async_build_control_hosts( + self.hass, + device_unique_name=self._device_unique_name, + backend_ip=self._device_ip, + ) + if not hosts: + raise HomeAssistantError( + "Device host is missing; reload or reconfigure the STIPS IRU1 integration, " + "or update the device IP in the config entry if it has changed." + ) + if live_ip: + self._device_ip_live = live_ip + + session = async_get_clientsession(self.hass) + auth = None + timeout = aiohttp.ClientTimeout( + total=3, connect=1.5, sock_connect=1.5, sock_read=2 + ) + params = { + "signal": signal, + "frequency": str(self._frequency), + "remoteType": self._remote_type, + } + last_error: Exception | None = None + for host in hosts: + url = f"http://{host}/local-ir/send" + try: + async with session.post( + url, data=params, auth=auth, timeout=timeout + ) as response: + if response.status >= 400: + body = await response.text() + last_error = HomeAssistantError( + f"Local IR request failed ({response.status}) via {host}: {body[:160]}" + ) + continue + return + except (TimeoutError, aiohttp.ClientError) as err: + last_error = err + continue + if isinstance(last_error, HomeAssistantError): + raise HomeAssistantError(f"{last_error} | hosts={', '.join(hosts)}") + detail = str(last_error).strip() or type(last_error).__name__ + raise HomeAssistantError( + f"Cannot reach IR device locally (hosts: {', '.join(hosts)}): {detail}" + ) from last_error diff --git a/homeassistant/components/stips_iru1/config_flow.py b/homeassistant/components/stips_iru1/config_flow.py new file mode 100644 index 00000000000000..4be6be4b301253 --- /dev/null +++ b/homeassistant/components/stips_iru1/config_flow.py @@ -0,0 +1,97 @@ +"""Config flow for STIPS IRU1.""" + +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .api import ( + StipsApiAuthError, + StipsApiClient, + StipsApiError, + StipsApiPermissionError, +) +from .catalog import async_fetch_catalog_devices +from .const import CONF_API_HOST, CONF_PASSWORD, CONF_USERNAME, DEFAULT_API_HOST, DOMAIN + + +class StipsIru1ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle STIPS IRU1 config flow.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Single-step setup: login and download full account IR catalog.""" + errors: dict[str, str] = {} + if user_input is not None: + api_host = user_input[CONF_API_HOST] + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + + session = async_get_clientsession(self.hass) + client = StipsApiClient(host=api_host, session=session) + try: + await client.login(username, password) + areas = await client.get_areas() + except StipsApiAuthError: + errors["base"] = "invalid_auth" + except StipsApiPermissionError: + errors["base"] = "no_catalog_permission" + except StipsApiError: + errors["base"] = "cannot_connect" + except TypeError: + errors["base"] = "unknown" + except ValueError: + errors["base"] = "unknown" + else: + if not areas: + errors["base"] = "no_areas" + else: + try: + _, catalog_devices = await async_fetch_catalog_devices( + client, areas + ) + except StipsApiError: + errors["base"] = "cannot_connect" + else: + if not catalog_devices: + errors["base"] = "no_devices" + else: + normalized_host = str(api_host).strip().lower() + normalized_username = str(username).strip().lower() + await self.async_set_unique_id( + f"{DOMAIN}_{normalized_host}_{normalized_username}" + ) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"STIPS ({username})", + data={ + CONF_API_HOST: api_host, + CONF_USERNAME: username, + "areas": areas, + "devices": catalog_devices, + }, + ) + + schema = vol.Schema( + { + vol.Required(CONF_API_HOST, default=DEFAULT_API_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } + ) + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) diff --git a/homeassistant/components/stips_iru1/const.py b/homeassistant/components/stips_iru1/const.py new file mode 100644 index 00000000000000..91bba94b02c0a9 --- /dev/null +++ b/homeassistant/components/stips_iru1/const.py @@ -0,0 +1,51 @@ +"""Constants for STIPS IRU1 integration.""" + +from homeassistant.const import Platform + +DOMAIN = "stips_iru1" +PLATFORMS: list[Platform] = [Platform.CLIMATE] + +CONF_API_HOST = "api_host" +CONF_USERNAME = "username" +CONF_PASSWORD = "password" +CONF_AREA_ID = "area_id" +CONF_DEVICE_UNIQUE_NAME = "device_unique_name" +CONF_DEVICE_IP = "device_ip" + +DEFAULT_API_HOST = "stips.api.staging.visionalization.net" + +DATA_CLIENT = "client" + + +def normalize_remote_type(remote_type: str | None) -> str: + """Normalize STIPS remote type labels for comparisons.""" + if not remote_type: + return "" + return remote_type.strip().lower().replace(" ", "") + + +def is_protocol_ac(remote_type: str | None) -> bool: + """True for backend IRac remotes (type ``AC``), not LearnedAc.""" + return normalize_remote_type(remote_type) == "ac" + + +def is_learned_ac(remote_type: str | None) -> bool: + """True for LearnedAc remotes built from learned signal sets.""" + return normalize_remote_type(remote_type) == "learnedac" + + +def remote_uses_signal_buttons(remote_type: str | None) -> bool: + """True if HA should expose a signal-based `remote.*` for this STIPS remote. + + Protocol AC remotes are controlled via IRac status (type/model/power/mode/...), not + per-button raw signals. LearnedAc is exposed via `climate.*` and does not get a signal + button remote entity. Learned TV-like remotes (and similar) use downloaded button signals. + """ + t = normalize_remote_type(remote_type) + if not t: + return True + if t == "learnedac": + return False + if t.startswith("learned"): + return True + return t != "ac" diff --git a/homeassistant/components/stips_iru1/local_http.py b/homeassistant/components/stips_iru1/local_http.py new file mode 100644 index 00000000000000..a85d1e971516f1 --- /dev/null +++ b/homeassistant/components/stips_iru1/local_http.py @@ -0,0 +1,191 @@ +"""Local HTTP helpers for DNS-first IRU1 control.""" + +from __future__ import annotations + +from dataclasses import dataclass +import ipaddress +import json +import re +import time +from typing import Any + +import aiohttp +from yarl import URL + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +_HOST_LABEL_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$") +_HOST_CACHE_TTL_SECONDS = 60.0 + + +@dataclass(slots=True) +class _HostCacheEntry: + hosts: list[str] + live_ip: str + expires_at: float + + +_HOST_CACHE: dict[tuple[str, str], _HostCacheEntry] = {} + + +def _local_http_auth() -> aiohttp.BasicAuth | None: + """Build optional auth for local endpoint calls. + + LAN auth credentials are device/user-specific and must not be hardcoded in core. + """ + return None + + +def _dedupe_hosts(hosts: list[str]) -> list[str]: + out: list[str] = [] + for host in hosts: + h = str(host or "").strip() + if h and h not in out: + out.append(h) + return out + + +def _is_valid_catalog_hostname(value: str) -> bool: + """Validate cloud-provided host name to avoid SSRF primitives.""" + s = value.strip().lower() + if not s or len(s) > 253: + return False + if any(ch.isspace() for ch in s): + return False + if any(ch in s for ch in (":", "/", "@", "?", "#", "%", "\\")): + return False + try: + ipaddress.ip_address(s) + except ValueError: + pass + else: + return False + labels = s.split(".") + if any(not label for label in labels): + return False + return all(_HOST_LABEL_RE.fullmatch(label) for label in labels) + + +def iter_dns_host_candidates( + device_unique_name: str, backend_ip: str = "" +) -> list[str]: + """Build DNS-first host candidates with backend IP as a last resort.""" + hosts: list[str] = [] + unique_name = str(device_unique_name or "").strip().lower() + if _is_valid_catalog_hostname(unique_name): + hosts.append(unique_name) + if "." not in unique_name: + hosts.append(f"{unique_name}.local") + + ip_s = str(backend_ip or "").strip() + if ip_s: + try: + ipaddress.ip_address(ip_s) + hosts.append(ip_s) + except ValueError: + # Keep malformed/non-IP values out of host candidates. + pass + return _dedupe_hosts(hosts) + + +def _extract_live_ip(payload: dict[str, Any]) -> str: + for key in ("ip_address", "ipAddress", "ip", "Ip"): + val = payload.get(key) + if val is None: + continue + s = str(val).strip() + if not s: + continue + try: + ipaddress.ip_address(s) + except ValueError: + continue + else: + return s + return "" + + +async def async_fetch_device_info_live_ip( + hass: HomeAssistant, + *, + host: str, + timeout: aiohttp.ClientTimeout, +) -> str: + """Try GET /device_info on one host and return a validated IP when available.""" + session = async_get_clientsession(hass) + auth = _local_http_auth() + url = str(URL.build(scheme="http", host=host, path="/device_info")) + try: + async with session.get(url, auth=auth, timeout=timeout) as response: + if response.status >= 400: + return "" + try: + payload = await response.json(content_type=None) + except aiohttp.ContentTypeError: + body = await response.text() + try: + payload = json.loads(body) + except json.JSONDecodeError: + return "" + except ValueError: + body = await response.text() + try: + payload = json.loads(body) + except json.JSONDecodeError: + return "" + if not isinstance(payload, dict): + return "" + return _extract_live_ip(payload) + except TimeoutError: + return "" + except aiohttp.ClientError: + return "" + + +async def async_build_control_hosts( + hass: HomeAssistant, + *, + device_unique_name: str, + backend_ip: str, +) -> tuple[list[str], str]: + """Resolve command host list and live IP using DNS first, backend IP last. + + Returns (hosts, live_ip). live_ip is empty when unavailable. + """ + cache_key = ( + str(device_unique_name or "").strip().lower(), + str(backend_ip or "").strip(), + ) + now = time.monotonic() + cache_entry = _HOST_CACHE.get(cache_key) + if cache_entry is not None and cache_entry.expires_at > now: + return list(cache_entry.hosts), cache_entry.live_ip + + hosts = iter_dns_host_candidates(device_unique_name, backend_ip) + if not hosts: + return [], "" + + probe_timeout = aiohttp.ClientTimeout( + total=2, connect=1, sock_connect=1, sock_read=1.5 + ) + live_ip = "" + for host in hosts: + ip = await async_fetch_device_info_live_ip( + hass, host=host, timeout=probe_timeout + ) + if ip: + live_ip = ip + break + + if live_ip and live_ip not in hosts: + # Keep DNS hosts first as requested; add discovered IP as fallback. + hosts.append(live_ip) + deduped_hosts = _dedupe_hosts(hosts) + _HOST_CACHE[cache_key] = _HostCacheEntry( + hosts=deduped_hosts, + live_ip=live_ip, + expires_at=now + _HOST_CACHE_TTL_SECONDS, + ) + + return deduped_hosts, live_ip diff --git a/homeassistant/components/stips_iru1/manifest.json b/homeassistant/components/stips_iru1/manifest.json new file mode 100644 index 00000000000000..792dc638974d40 --- /dev/null +++ b/homeassistant/components/stips_iru1/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "stips_iru1", + "name": "STIPS IR Remote", + "codeowners": ["@stips"], + "config_flow": true, + "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/stips_iru1/", + "integration_type": "hub", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["stips-api-bridge==0.1.0"] +} diff --git a/homeassistant/components/stips_iru1/quality_scale.yaml b/homeassistant/components/stips_iru1/quality_scale.yaml new file mode 100644 index 00000000000000..af76aff04a755e --- /dev/null +++ b/homeassistant/components/stips_iru1/quality_scale.yaml @@ -0,0 +1,60 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: 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: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: todo + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: todo + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/stips_iru1/strings.json b/homeassistant/components/stips_iru1/strings.json new file mode 100644 index 00000000000000..5bdc878ce59372 --- /dev/null +++ b/homeassistant/components/stips_iru1/strings.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "This STIPS account/API host is already configured" + }, + "error": { + "cannot_connect": "Cannot connect to STIPS API", + "invalid_auth": "Invalid username/password", + "no_areas": "No areas found for this account", + "no_catalog_permission": "Login succeeded, but this account has no permission to read IR catalog endpoints", + "no_devices": "No IR devices found in selected area", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "api_host": "API host", + "password": "Password", + "username": "Username" + }, + "data_description": { + "api_host": "The STIPS API host (e.g., stips.api.staging.visionalization.net)", + "password": "Your STIPS account password", + "username": "Your STIPS account username" + }, + "description": "Login is used only to download your full IR catalog (all areas/devices/remotes). Control runs locally over LAN, so internet is not required for normal IR sends after setup.", + "title": "Connect STIPS account" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 7f42a179a49ded..4d56565bf38cea 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -692,6 +692,7 @@ "steam_online", "steamist", "stiebel_eltron", + "stips_iru1", "stookwijzer", "streamlabswater", "subaru", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 28600a5f462780..503e54f6dae5e2 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6684,6 +6684,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "stips_iru1": { + "name": "STIPS IR Remote", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "stookwijzer": { "name": "Stookwijzer", "integration_type": "service", diff --git a/homeassistant/helpers/template/__init__.py b/homeassistant/helpers/template/__init__.py index 8cb247c6917475..2f63db417b8c61 100644 --- a/homeassistant/helpers/template/__init__.py +++ b/homeassistant/helpers/template/__init__.py @@ -6,6 +6,7 @@ import asyncio import collections.abc from collections.abc import Callable, Iterable +import contextlib from datetime import timedelta from functools import lru_cache, partial, wraps import logging @@ -468,7 +469,8 @@ def _render_template() -> None: raise TemplateError(self._exc_info[1].with_traceback(self._exc_info[2])) except TimeoutError: if template_render_thread.is_alive(): - template_render_thread.raise_exc(TimeoutError) + with contextlib.suppress(SystemError): + template_render_thread.raise_exc(TimeoutError) return True finally: template_render_thread.join() diff --git a/requirements_all.txt b/requirements_all.txt index 430fcd2924f2c8..0d6c3ccc145d9d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3030,6 +3030,9 @@ steamloop==1.2.0 # homeassistant.components.steam_online steamodd==4.21 +# homeassistant.components.stips_iru1 +stips-api-bridge==0.1.0 + # homeassistant.components.stookwijzer stookwijzer==1.6.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0636f8b7683246..e662c466137203 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2572,6 +2572,9 @@ steamloop==1.2.0 # homeassistant.components.steam_online steamodd==4.21 +# homeassistant.components.stips_iru1 +stips-api-bridge==0.1.0 + # homeassistant.components.stookwijzer stookwijzer==1.6.1 diff --git a/script/licenses.py b/script/licenses.py index 01839a9e62c535..b04b4e253ce549 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -200,6 +200,7 @@ def from_dict(cls, data: PackageMetadata) -> PackageDefinition: "pybbox", # https://github.com/HydrelioxGitHub/pybbox/pull/5 "pysabnzbd", # https://github.com/jeradM/pysabnzbd/pull/6 "sharp_aquos_rc", # https://github.com/jmoore987/sharp_aquos_rc/pull/14 + "stips-api-bridge", # License metadata is currently missing on PyPI "tapsaff", # https://github.com/bazwilliams/python-taps-aff/pull/5 "ujson", # https://github.com/ultrajson/ultrajson/blob/main/LICENSE.txt } diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index d5431ac81897ed..c4d50b4da7c15f 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -136,6 +136,8 @@ async def test_all_day_event( mock_events_list_items([event]) assert await component_setup() + await hass.async_block_till_done() + await hass.async_block_till_done() state = hass.states.get(TEST_ENTITY) assert state.name == TEST_ENTITY_NAME @@ -167,6 +169,8 @@ async def test_future_event( mock_events_list_items([event]) assert await component_setup() + await hass.async_block_till_done() + await hass.async_block_till_done() state = hass.states.get(TEST_ENTITY) assert state.name == TEST_ENTITY_NAME @@ -198,6 +202,8 @@ async def test_in_progress_event( mock_events_list_items([event]) assert await component_setup() + await hass.async_block_till_done() + await hass.async_block_till_done() state = hass.states.get(TEST_ENTITY) assert state.name == TEST_ENTITY_NAME @@ -1294,6 +1300,8 @@ async def test_all_day_event_without_duration( mock_events_list_items([event]) assert await component_setup() + await hass.async_block_till_done() + await hass.async_block_till_done() expected_end_event = week_from_today + datetime.timedelta(days=1) diff --git a/tests/components/stips_iru1/__init__.py b/tests/components/stips_iru1/__init__.py new file mode 100644 index 00000000000000..7845497c71c070 --- /dev/null +++ b/tests/components/stips_iru1/__init__.py @@ -0,0 +1 @@ +"""Tests for the stips_iru1 integration.""" diff --git a/tests/components/stips_iru1/test_climate.py b/tests/components/stips_iru1/test_climate.py new file mode 100644 index 00000000000000..a0edf7dad374a1 --- /dev/null +++ b/tests/components/stips_iru1/test_climate.py @@ -0,0 +1,563 @@ +"""Tests for stips_iru1 climate entities.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.climate import HVACMode +from homeassistant.components.stips_iru1 import climate as stips_climate +from homeassistant.components.stips_iru1.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC + +from tests.common import MockConfigEntry + + +@pytest.fixture +def protocol_ac_entity(hass: HomeAssistant) -> stips_climate.StipsIruClimate: + """Create a protocol AC climate entity for testing.""" + return stips_climate.StipsIruClimate( + hass=hass, + device_unique_name="stips-iru1-12345", + device_name="Test Device", + device_ip="192.168.1.100", + device_mac="AA:BB:CC:DD:EE:FF", + device_online=True, + remote_id="1", + friendly_name="Test AC", + remote_snapshot={"type": "AC", "model": {"protocol": 42}}, + ) + + +@pytest.fixture +def learned_ac_entity(hass: HomeAssistant) -> stips_climate.StipsIruLearnedAcClimate: + """Create a learned AC climate entity for testing.""" + return stips_climate.StipsIruLearnedAcClimate( + hass=hass, + device_unique_name="stips-iru1-67890", + device_name="Learned Device", + device_ip="192.168.1.101", + device_mac="11:22:33:44:55:66", + device_online=True, + remote_id="2", + friendly_name="Learned AC", + remote_snapshot={ + "type": "LearnedAc", + "model": { + "frequency": 38000, + "signals": [ + { + "mode": "cool", + "temperature": 22, + "fanSpeed": "medium", + "signal": "COOL_22_MED", + }, + { + "mode": "heat", + "temperature": 20, + "fanSpeed": "low", + "signal": "HEAT_20_LOW", + }, + ], + "powerOnSignal": "POWER_ON", + "powerOffSignal": "POWER_OFF", + }, + }, + ) + + +def _mock_success_post(mock_session: MagicMock, status: int = 200) -> None: + response = AsyncMock() + response.status = status + context = AsyncMock() + context.__aenter__.return_value = response + context.__aexit__.return_value = None + mock_session.post.return_value = context + + +class TestProtocolAcClimate: + """Tests for protocol AC climate entity.""" + + def test_device_info( + self, protocol_ac_entity: stips_climate.StipsIruClimate + ) -> None: + """Validate device registry metadata.""" + info = protocol_ac_entity.device_info + assert info is not None + assert (DOMAIN, "stips-iru1-12345") in info["identifiers"] + assert (CONNECTION_NETWORK_MAC, "AA:BB:CC:DD:EE:FF") in info["connections"] + + def test_properties( + self, protocol_ac_entity: stips_climate.StipsIruClimate + ) -> None: + """Validate protocol AC property mapping.""" + assert protocol_ac_entity.available is True + protocol_ac_entity._state.update( + { + "power": 1, + "mode": 2, + "fan": 4, + "temp": 24, + "swingV": 1, + "swingH": 0, + } + ) + + assert protocol_ac_entity.hvac_mode is HVACMode.HEAT + assert protocol_ac_entity.target_temperature == 24.0 + assert protocol_ac_entity.fan_mode == "high" + assert protocol_ac_entity.swing_mode == "vertical" + assert protocol_ac_entity.extra_state_attributes["protocol"] == 42 + + def test_properties_when_powered_off( + self, protocol_ac_entity: stips_climate.StipsIruClimate + ) -> None: + """HVAC mode should report OFF when cached power is off.""" + protocol_ac_entity._state["power"] = 0 + + assert protocol_ac_entity.hvac_mode is HVACMode.OFF + + def test_device_info_without_mac(self, hass: HomeAssistant) -> None: + """Device info should omit network connections when MAC is missing.""" + entity = stips_climate.StipsIruClimate( + hass=hass, + device_unique_name="stips-iru1-no-mac", + device_name="No MAC Device", + device_ip="192.168.1.100", + device_mac="", + device_online=True, + remote_id="1", + friendly_name="No MAC", + remote_snapshot={"type": "AC", "model": {"protocol": 42}}, + ) + + info = entity.device_info + assert info is not None + assert info["connections"] == set() + + async def test_basic_controls( + self, protocol_ac_entity: stips_climate.StipsIruClimate + ) -> None: + """Validate user control methods map to _send_update.""" + with patch.object(protocol_ac_entity, "_send_update", new=AsyncMock()) as send: + await protocol_ac_entity.async_turn_on() + send.assert_called_with(power=1) + + await protocol_ac_entity.async_turn_off() + send.assert_called_with(power=0) + + await protocol_ac_entity.async_set_hvac_mode(HVACMode.OFF) + send.assert_called_with(power=0) + + await protocol_ac_entity.async_set_hvac_mode(HVACMode.HEAT) + assert send.call_args.kwargs["mode"] == 2 + + await protocol_ac_entity.async_set_temperature(temperature=50) + assert send.call_args.kwargs["temp"] == 30 + + await protocol_ac_entity.async_set_fan_mode("high") + assert send.call_args.kwargs["fan"] == 4 + + await protocol_ac_entity.async_set_swing_mode("both") + assert send.call_args.kwargs["swingV"] == 1 + assert send.call_args.kwargs["swingH"] == 1 + + async def test_invalid_controls( + self, protocol_ac_entity: stips_climate.StipsIruClimate + ) -> None: + """Validate validation errors for invalid control inputs.""" + with pytest.raises(HomeAssistantError, match="Temperature is required"): + await protocol_ac_entity.async_set_temperature() + with pytest.raises(HomeAssistantError, match="Unsupported fan mode"): + await protocol_ac_entity.async_set_fan_mode("bad") + with pytest.raises(HomeAssistantError, match="Unsupported HVAC mode"): + await protocol_ac_entity.async_set_hvac_mode(HVACMode.HEAT_COOL) + + async def test_send_update_error_paths( + self, protocol_ac_entity: stips_climate.StipsIruClimate, hass: HomeAssistant + ) -> None: + """Validate _send_update failure branches.""" + with ( + patch( + "homeassistant.components.stips_iru1.climate.async_build_control_hosts", + return_value=([], None), + ), + pytest.raises(HomeAssistantError, match="Device host is missing"), + ): + await protocol_ac_entity._send_update(power=1) + + no_proto = stips_climate.StipsIruClimate( + hass=hass, + device_unique_name="test", + device_name="test", + device_ip="", + device_mac="", + device_online=True, + remote_id="1", + friendly_name="test", + remote_snapshot={"type": "AC", "model": {}}, + ) + with ( + patch( + "homeassistant.components.stips_iru1.climate.async_build_control_hosts", + return_value=(["host"], None), + ), + pytest.raises(HomeAssistantError, match="AC protocol is missing"), + ): + await no_proto._send_update(power=1) + + async def test_send_update_success_and_timeout( + self, protocol_ac_entity: stips_climate.StipsIruClimate + ) -> None: + """Validate timeout and success behavior of _send_update.""" + with patch( + "homeassistant.components.stips_iru1.climate.async_build_control_hosts", + return_value=(["host1"], "10.0.0.8"), + ): + with patch( + "homeassistant.components.stips_iru1.climate.async_get_clientsession" + ) as get_session: + session = MagicMock() + get_session.return_value = session + session.post = MagicMock(side_effect=TimeoutError()) + + with pytest.raises(HomeAssistantError, match="Cannot reach IR device"): + await protocol_ac_entity._send_update(power=1) + + with patch( + "homeassistant.components.stips_iru1.climate.async_get_clientsession" + ) as get_session: + session = MagicMock() + get_session.return_value = session + _mock_success_post(session) + with patch.object(protocol_ac_entity, "async_write_ha_state"): + await protocol_ac_entity._send_update(power=1) + + assert protocol_ac_entity._attr_available is True + assert protocol_ac_entity._device_ip_live == "10.0.0.8" + + async def test_send_update_http_error( + self, protocol_ac_entity: stips_climate.StipsIruClimate + ) -> None: + """Validate HTTP error handling for local AC requests.""" + response = AsyncMock() + response.status = 500 + response.text = AsyncMock(return_value="failure") + context = AsyncMock() + context.__aenter__.return_value = response + context.__aexit__.return_value = None + + with ( + patch( + "homeassistant.components.stips_iru1.climate.async_build_control_hosts", + return_value=(["host1"], None), + ), + patch( + "homeassistant.components.stips_iru1.climate.async_get_clientsession" + ) as get_session, + ): + session = MagicMock() + session.post = MagicMock(return_value=context) + get_session.return_value = session + + with pytest.raises(HomeAssistantError, match="Local AC request failed"): + await protocol_ac_entity._send_update(power=1) + + +async def test_async_setup_entry_creates_expected_entities( + hass: HomeAssistant, +) -> None: + """Climate setup should create protocol and learned AC entities only.""" + entry: MockConfigEntry = MockConfigEntry( + domain=DOMAIN, + data={ + "devices": [ + { + "uniqueName": "stips-iru1-12345", + "name": "Main Device", + "deviceIP": "192.168.1.10", + "deviceMac": "AA:BB:CC:DD:EE:FF", + "online": True, + "remotes": [ + { + "id": 0, + "type": "AC", + "friendlyName": "Protocol AC", + "model": {"protocol": 7}, + }, + { + "id": "skip-ac", + "type": "AC", + "friendlyName": "Broken AC", + "model": {}, + }, + { + "id": "learned-ac", + "type": "LearnedAc", + "friendlyName": "Learned AC", + "model": { + "frequency": 38000, + "signals": [ + { + "mode": "cool", + "temperature": 22, + "fanSpeed": "medium", + "signal": "COOL", + } + ], + }, + }, + ], + }, + { + "name": "Ignored Device", + "deviceIP": "192.168.1.11", + "deviceMac": "00:11:22:33:44:55", + "online": True, + "remotes": [ + { + "id": "ignored", + "type": "AC", + "friendlyName": "Ignored AC", + "model": {"protocol": 8}, + } + ], + }, + ] + }, + ) + entities: list[stips_climate.ClimateEntity] = [] + + await stips_climate.async_setup_entry(hass, entry, entities.extend) + + assert len(entities) == 2 + assert any(entity.unique_id.endswith("_climate_0") for entity in entities) + assert any("learned_ac" in entity.unique_id for entity in entities) + + +class TestLearnedAcClimate: + """Tests for learned AC climate entity.""" + + def test_properties( + self, learned_ac_entity: stips_climate.StipsIruLearnedAcClimate + ) -> None: + """Validate baseline properties and derived modes.""" + assert learned_ac_entity.available is True + info = learned_ac_entity.device_info + assert info is not None + assert (DOMAIN, "stips-iru1-67890") in info["identifiers"] + assert (CONNECTION_NETWORK_MAC, "11:22:33:44:55:66") in info["connections"] + assert HVACMode.OFF in learned_ac_entity._attr_hvac_modes + assert HVACMode.COOL in learned_ac_entity._attr_hvac_modes + assert "medium" in learned_ac_entity._attr_fan_modes + assert learned_ac_entity.target_temperature == 21.0 + assert learned_ac_entity.fan_mode == "low" + assert learned_ac_entity.extra_state_attributes["learned_signal_count"] == 2 + + def test_default_modes_without_learned_rows(self, hass: HomeAssistant) -> None: + """Empty learned signal sets should fall back to safe default modes.""" + entity = stips_climate.StipsIruLearnedAcClimate( + hass=hass, + device_unique_name="stips-iru1-empty", + device_name="Empty Learned Device", + device_ip="192.168.1.102", + device_mac="", + device_online=True, + remote_id="3", + friendly_name="Empty Learned", + remote_snapshot={ + "type": "LearnedAc", + "model": { + "frequency": 38000, + "signals": [], + }, + }, + ) + + assert entity._attr_hvac_modes == [HVACMode.OFF, HVACMode.COOL] + assert entity._attr_fan_modes == ["medium"] + + async def test_basic_controls( + self, learned_ac_entity: stips_climate.StipsIruLearnedAcClimate + ) -> None: + """Validate learned AC control methods map to _send_state or _post_signal.""" + with patch.object(learned_ac_entity, "_send_state", new=AsyncMock()) as send: + await learned_ac_entity.async_turn_on() + send.assert_called_with(power=1, hvac_mode=HVACMode.COOL) + + await learned_ac_entity.async_set_hvac_mode(HVACMode.HEAT) + assert send.call_args.kwargs["hvac_mode"] == HVACMode.HEAT + + await learned_ac_entity.async_set_temperature(temperature=50) + assert send.call_args.kwargs["temp"] == 22 + + await learned_ac_entity.async_set_fan_mode("low") + assert send.call_args.kwargs["fan"] == "low" + + with patch.object(learned_ac_entity, "_post_signal", new=AsyncMock()) as post: + with patch.object(learned_ac_entity, "async_write_ha_state"): + await learned_ac_entity.async_turn_off() + post.assert_called_once_with("POWER_OFF") + + learned_ac_entity._state["power"] = 1 + with patch.object(learned_ac_entity, "_send_state", new=AsyncMock()) as send: + await learned_ac_entity.async_turn_on() + send.assert_called_once_with(power=1) + + learned_ac_entity._power_off_signal = None + with patch.object(learned_ac_entity, "_send_state", new=AsyncMock()) as send: + await learned_ac_entity.async_turn_off() + send.assert_called_once_with(power=0) + + with patch.object( + learned_ac_entity, "async_turn_off", new=AsyncMock() + ) as turn_off: + await learned_ac_entity.async_set_hvac_mode(HVACMode.OFF) + turn_off.assert_awaited_once() + + async def test_fan_mode_validation( + self, learned_ac_entity: stips_climate.StipsIruLearnedAcClimate + ) -> None: + """Unsupported fan mode should raise immediately.""" + with pytest.raises(HomeAssistantError, match="Unsupported fan mode"): + await learned_ac_entity.async_set_fan_mode("unsupported") + with pytest.raises(HomeAssistantError, match="Temperature is required"): + await learned_ac_entity.async_set_temperature() + + async def test_send_state_paths( + self, learned_ac_entity: stips_climate.StipsIruLearnedAcClimate + ) -> None: + """Validate matching signal, fallback signal, and no-match errors.""" + with ( + patch.object(learned_ac_entity, "_post_signal", new=AsyncMock()) as post, + patch.object(learned_ac_entity, "async_write_ha_state"), + ): + await learned_ac_entity._send_state( + power=1, + hvac_mode=HVACMode.COOL, + temp=22, + fan="medium", + ) + post.assert_called_once_with("COOL_22_MED") + + with ( + patch.object(learned_ac_entity, "_post_signal", new=AsyncMock()) as post, + patch.object(learned_ac_entity, "async_write_ha_state"), + ): + await learned_ac_entity._send_state(power=0) + post.assert_called_once_with("POWER_OFF") + assert learned_ac_entity._state["power"] == 0 + + learned_ac_entity._power_on_signal = "POWER_ON" + with ( + patch.object(learned_ac_entity, "_post_signal", new=AsyncMock()) as post, + patch.object(learned_ac_entity, "async_write_ha_state"), + ): + await learned_ac_entity._send_state(power=1, hvac_mode=HVACMode.FAN_ONLY) + post.assert_called_once_with("POWER_ON") + + learned_ac_entity._power_on_signal = None + with pytest.raises(HomeAssistantError, match="No learned AC signal matches"): + await learned_ac_entity._send_state(power=1, hvac_mode=HVACMode.FAN_ONLY) + + async def test_post_signal_hosts_and_fallback( + self, learned_ac_entity: stips_climate.StipsIruLearnedAcClimate + ) -> None: + """Validate host missing, timeout, and fallback-to-second-host behavior.""" + with ( + patch( + "homeassistant.components.stips_iru1.climate.async_build_control_hosts", + return_value=([], None), + ), + pytest.raises(HomeAssistantError, match="Device host is missing"), + ): + await learned_ac_entity._post_signal("SIG") + + with ( + patch( + "homeassistant.components.stips_iru1.climate.async_build_control_hosts", + return_value=(["host1"], None), + ), + patch( + "homeassistant.components.stips_iru1.climate.async_get_clientsession" + ) as get_session, + ): + session = MagicMock() + get_session.return_value = session + session.post = MagicMock(side_effect=TimeoutError()) + + with ( + patch.object(learned_ac_entity, "async_write_ha_state"), + pytest.raises(HomeAssistantError, match="Cannot reach IR device"), + ): + await learned_ac_entity._post_signal("SIG") + + with ( + patch( + "homeassistant.components.stips_iru1.climate.async_build_control_hosts", + return_value=(["host1", "host2"], None), + ), + patch( + "homeassistant.components.stips_iru1.climate.async_get_clientsession" + ) as get_session, + ): + session = MagicMock() + get_session.return_value = session + + response = AsyncMock() + response.status = 200 + context = AsyncMock() + context.__aenter__.return_value = response + context.__aexit__.return_value = None + session.post.side_effect = [TimeoutError(), context] + + with patch.object(learned_ac_entity, "async_write_ha_state"): + await learned_ac_entity._post_signal("SIG") + + assert session.post.call_count == 2 + + with ( + patch( + "homeassistant.components.stips_iru1.climate.async_build_control_hosts", + return_value=(["host1"], "10.0.0.9"), + ), + patch( + "homeassistant.components.stips_iru1.climate.async_get_clientsession" + ) as get_session, + ): + session = MagicMock() + get_session.return_value = session + _mock_success_post(session) + + with patch.object(learned_ac_entity, "async_write_ha_state"): + await learned_ac_entity._post_signal("SIG") + + assert learned_ac_entity._device_ip_live == "10.0.0.9" + + async def test_post_signal_http_error( + self, learned_ac_entity: stips_climate.StipsIruLearnedAcClimate + ) -> None: + """Validate HTTP error handling for learned AC signal posts.""" + response = AsyncMock() + response.status = 500 + response.text = AsyncMock(return_value="failure") + context = AsyncMock() + context.__aenter__.return_value = response + context.__aexit__.return_value = None + + with ( + patch( + "homeassistant.components.stips_iru1.climate.async_build_control_hosts", + return_value=(["host1"], None), + ), + patch( + "homeassistant.components.stips_iru1.climate.async_get_clientsession" + ) as get_session, + ): + session = MagicMock() + session.post = MagicMock(return_value=context) + get_session.return_value = session + + with pytest.raises(HomeAssistantError, match="Local IR request failed"): + await learned_ac_entity._post_signal("SIG") diff --git a/tests/components/stips_iru1/test_climate_helpers.py b/tests/components/stips_iru1/test_climate_helpers.py new file mode 100644 index 00000000000000..30133b18f2cdf9 --- /dev/null +++ b/tests/components/stips_iru1/test_climate_helpers.py @@ -0,0 +1,242 @@ +"""Helper-level tests for stips_iru1 climate module.""" + +import pytest + +from homeassistant.components.climate import HVACMode +from homeassistant.components.stips_iru1 import climate as stips_climate +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + + +def test_swing_mode_roundtrip() -> None: + """Swing mode split/normalize should map expected combinations.""" + assert stips_climate._split_swing_mode("off") == (0, 0) + assert stips_climate._split_swing_mode("vertical") == (1, 0) + assert stips_climate._split_swing_mode("horizontal") == (0, 1) + assert stips_climate._split_swing_mode("both") == (1, 1) + + assert stips_climate._normalize_swing_mode(0, 0) == "off" + assert stips_climate._normalize_swing_mode(1, 0) == "vertical" + assert stips_climate._normalize_swing_mode(0, 1) == "horizontal" + assert stips_climate._normalize_swing_mode(1, 1) == "both" + + +def test_mode_and_fan_normalization() -> None: + """Mode/fan helper normalization should accept aliases and ints.""" + assert stips_climate._mode_to_hvac("fan_only") == HVACMode.FAN_ONLY + assert stips_climate._mode_to_hvac("cool") == HVACMode.COOL + assert stips_climate._fan_to_name("4") == "high" + assert stips_climate._fan_to_name("med") == "medium" + + +def test_helper_edge_case_normalization() -> None: + """Helper normalization should cover fallback and alias branches.""" + assert stips_climate._safe_int(None, 7) == 7 + assert stips_climate._safe_int("bad", 9) == 9 + assert stips_climate._mode_to_hvac("auto") == HVACMode.AUTO + assert stips_climate._mode_to_hvac("heat") == HVACMode.HEAT + assert stips_climate._mode_to_hvac("dry") == HVACMode.DRY + assert stips_climate._mode_to_hvac("fanonly") == HVACMode.FAN_ONLY + assert stips_climate._mode_to_hvac("unknown") == HVACMode.COOL + assert stips_climate._fan_to_name("mid") == "medium" + assert stips_climate._fan_to_name("maximum") == "max" + assert stips_climate._fan_to_name("minimum") == "min" + assert stips_climate._fan_to_name("99") == "medium" + + +def test_learned_ac_signal_extraction_and_pick() -> None: + """Learned AC helpers should parse signals and pick best match.""" + remote_snapshot = { + "model": { + "frequency": 38000, + "signals": [ + { + "mode": "cool", + "temperature": 22, + "fanSpeed": "medium", + "signal": "A", + }, + { + "mode": "cool", + "temperature": 23, + "fanSpeed": "medium", + "signal": "B", + }, + ], + "powerOnSignal": "PON", + "powerOffSignal": "POFF", + } + } + entries, power_on, power_off, freq = stips_climate._extract_learned_ac_signals( + remote_snapshot + ) + assert len(entries) == 2 + assert power_on == "PON" + assert power_off == "POFF" + assert freq == 38000 + + picked = stips_climate._pick_best_learned_signal( + entries, HVACMode.COOL, 23, "medium" + ) + assert picked == "B" + + +def test_learned_ac_signal_picker_skips_mismatches_and_blank_signals() -> None: + """Picker should skip bad fan matches and blank learned signal strings.""" + entries = [ + {"mode": "cool", "temp": 22, "fan": "low", "signal": "LOW_SIG"}, + {"mode": "cool", "temp": 22, "fan": "medium", "signal": " "}, + {"mode": "cool", "temp": 22, "fan": "medium", "signal": "GOOD_SIG"}, + ] + + assert ( + stips_climate._pick_best_learned_signal(entries, HVACMode.COOL, 22, "medium") + == "GOOD_SIG" + ) + + +def test_learned_ac_helpers_cover_uppercase_and_fallback_paths() -> None: + """Learned AC helpers should cover uppercase keys and invalid rows.""" + remote_snapshot = { + "model": { + "Frequency": "39000", + "Signals": [ + "skip-me", + {"Signal": " "}, + { + "Signal": "FAN_SIG", + "mode": "fanonly", + "temperature": "bad", + "fan": "maximum", + }, + { + "Signal": "AUTO_SIG", + "mode": "auto", + "temperature": 25, + "fan": "minimum", + }, + ], + "PowerOnSignal": " ", + "PowerOffSignal": "POFF", + } + } + + entries, power_on, power_off, freq = stips_climate._extract_learned_ac_signals( + remote_snapshot + ) + + assert freq == 39000 + assert power_on is None + assert power_off == "POFF" + assert entries == [ + {"mode": "fan", "temp": None, "fan": "max", "signal": "FAN_SIG"}, + {"mode": "auto", "temp": 25, "fan": "min", "signal": "AUTO_SIG"}, + ] + + assert ( + stips_climate._pick_best_learned_signal(entries, HVACMode.FAN_ONLY, 22, "max") + == "FAN_SIG" + ) + assert ( + stips_climate._pick_best_learned_signal(entries, HVACMode.HEAT, 25, "min") + is None + ) + + +def test_extract_initial_ac_state_fallback_paths() -> None: + """Initial AC state extraction should fall back cleanly.""" + fallback_remote = { + "acStatus": { + "lastModeName": "missing", + "modeStates": { + "first": { + "power": "1", + "mode": "4", + "fan": "2", + "temperature": "21", + } + }, + } + } + assert stips_climate._extract_initial_ac_state(fallback_remote)["mode"] == 4 + + default_remote = {"acStatus": {"modeStates": ["not-a-dict"]}} + assert stips_climate._extract_initial_ac_state(default_remote) == { + "power": 0, + "mode": 1, + "fan": 3, + "temp": 22, + "swingV": 0, + "swingH": 0, + "light": 1, + "beep": 1, + "econo": 0, + "filter": 0, + "turbo": 0, + "quiet": 0, + "clean": 0, + "sleep": 0, + } + + +def test_extract_initial_ac_state_prefers_last_mode_match() -> None: + """Initial AC state should prefer the exact last-mode match when present.""" + remote = { + "acStatus": { + "lastModeName": "heat", + "modeStates": { + "cool": {"power": "1", "mode": "1", "fan": "3", "temperature": "23"}, + "heat": {"power": "1", "mode": "2", "fan": "1", "temperature": "26"}, + }, + } + } + + assert stips_climate._extract_initial_ac_state(remote) == { + "power": 1, + "mode": 2, + "fan": 1, + "temp": 26, + "swingV": 0, + "swingH": 0, + "light": 1, + "beep": 1, + "econo": 0, + "filter": 0, + "turbo": 0, + "quiet": 0, + "clean": 0, + "sleep": 0, + } + + +async def test_learned_ac_rejects_unsupported_fan_mode( + hass: HomeAssistant, +) -> None: + """Unsupported fan mode should raise instead of silently mapping values.""" + entity = stips_climate.StipsIruLearnedAcClimate( + hass=hass, + device_unique_name="stips-iru1-abc123", + device_name="IR Unit", + device_ip="", + device_mac="", + device_online=True, + remote_id="1", + friendly_name="Living Room", + remote_snapshot={ + "type": "LearnedAc", + "model": { + "frequency": 38000, + "signals": [ + { + "mode": "cool", + "temperature": 24, + "fanSpeed": "medium", + "signal": "SIGNAL", + } + ], + }, + }, + ) + + with pytest.raises(HomeAssistantError, match="Unsupported fan mode"): + await entity.async_set_fan_mode("unsupported") diff --git a/tests/components/stips_iru1/test_config_flow.py b/tests/components/stips_iru1/test_config_flow.py new file mode 100644 index 00000000000000..f4ac7281c521f9 --- /dev/null +++ b/tests/components/stips_iru1/test_config_flow.py @@ -0,0 +1,336 @@ +"""Config flow tests for stips_iru1.""" + +from unittest.mock import AsyncMock, patch + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.stips_iru1.api import ( + StipsApiAuthError, + StipsApiError, + StipsApiPermissionError, +) +from homeassistant.components.stips_iru1.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_form_shows(hass: HomeAssistant) -> None: + """Test initial config flow 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 isinstance(result["data_schema"], vol.Schema) + + +async def test_create_entry_success(hass: HomeAssistant) -> None: + """Test config flow creates entry on successful auth and catalog fetch.""" + user_input = { + "api_host": "stips.api.staging.visionalization.net", + "username": "demo", + "password": "secret", + } + + with ( + patch( + "homeassistant.components.stips_iru1.config_flow.StipsApiClient.login", + new=AsyncMock(return_value=None), + ), + patch( + "homeassistant.components.stips_iru1.config_flow.StipsApiClient.get_areas", + new=AsyncMock(return_value=[{"id": 1, "name": "Home"}]), + ), + patch( + "homeassistant.components.stips_iru1.config_flow.async_fetch_catalog_devices", + new=AsyncMock(return_value=([], [{"uniqueName": "stips-iru1-123456"}])), + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == "create_entry" + + +async def test_permission_error(hass: HomeAssistant) -> None: + """Test permission error is handled.""" + user_input = { + "api_host": "stips.api.staging.visionalization.net", + "username": "demo", + "password": "secret", + } + + with patch( + "homeassistant.components.stips_iru1.config_flow.StipsApiClient.login", + new=AsyncMock(side_effect=StipsApiPermissionError()), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == "form" + assert result["errors"]["base"] == "no_catalog_permission" + + +async def test_no_areas_error(hass: HomeAssistant) -> None: + """Test no areas response is handled.""" + user_input = { + "api_host": "stips.api.staging.visionalization.net", + "username": "demo", + "password": "secret", + } + + with ( + patch( + "homeassistant.components.stips_iru1.config_flow.StipsApiClient.login", + new=AsyncMock(return_value=None), + ), + patch( + "homeassistant.components.stips_iru1.config_flow.StipsApiClient.get_areas", + new=AsyncMock(return_value=[]), + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == "form" + assert result["errors"]["base"] == "no_areas" + + +async def test_catalog_fetch_error(hass: HomeAssistant) -> None: + """Test catalog fetch errors are surfaced as connection issues.""" + user_input = { + "api_host": "stips.api.staging.visionalization.net", + "username": "demo", + "password": "secret", + } + + with ( + patch( + "homeassistant.components.stips_iru1.config_flow.StipsApiClient.login", + new=AsyncMock(return_value=None), + ), + patch( + "homeassistant.components.stips_iru1.config_flow.StipsApiClient.get_areas", + new=AsyncMock(return_value=[{"id": 1, "name": "Home"}]), + ), + patch( + "homeassistant.components.stips_iru1.config_flow.async_fetch_catalog_devices", + new=AsyncMock(side_effect=StipsApiError()), + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == "form" + assert result["errors"]["base"] == "cannot_connect" + + +async def test_login_connection_error(hass: HomeAssistant) -> None: + """Connection errors during login should map to cannot_connect.""" + user_input = { + "api_host": "stips.api.staging.visionalization.net", + "username": "demo", + "password": "secret", + } + + with patch( + "homeassistant.components.stips_iru1.config_flow.StipsApiClient.login", + new=AsyncMock(side_effect=StipsApiError()), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == "form" + assert result["errors"]["base"] == "cannot_connect" + + +async def test_unknown_error(hass: HomeAssistant) -> None: + """Test unexpected type/value errors are handled.""" + user_input = { + "api_host": "stips.api.staging.visionalization.net", + "username": "demo", + "password": "secret", + } + + with patch( + "homeassistant.components.stips_iru1.config_flow.StipsApiClient.login", + new=AsyncMock(side_effect=TypeError()), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == "form" + assert result["errors"]["base"] == "unknown" + + +async def test_unknown_value_error(hass: HomeAssistant) -> None: + """Test unexpected value errors are handled.""" + user_input = { + "api_host": "stips.api.staging.visionalization.net", + "username": "demo", + "password": "secret", + } + + with patch( + "homeassistant.components.stips_iru1.config_flow.StipsApiClient.login", + new=AsyncMock(side_effect=ValueError()), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == "form" + assert result["errors"]["base"] == "unknown" + + +async def test_auth_error(hass: HomeAssistant) -> None: + """Test auth error is handled.""" + user_input = { + "api_host": "stips.api.staging.visionalization.net", + "username": "bad", + "password": "wrong", + } + + with patch( + "homeassistant.components.stips_iru1.config_flow.StipsApiClient.login", + new=AsyncMock(side_effect=StipsApiAuthError()), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == "form" + + +async def test_no_devices_error(hass: HomeAssistant) -> None: + """Test no devices error is handled.""" + user_input = { + "api_host": "stips.api.staging.visionalization.net", + "username": "demo", + "password": "secret", + } + + with ( + patch( + "homeassistant.components.stips_iru1.config_flow.StipsApiClient.login", + new=AsyncMock(return_value=None), + ), + patch( + "homeassistant.components.stips_iru1.config_flow.StipsApiClient.get_areas", + new=AsyncMock(return_value=[{"id": 1, "name": "Home"}]), + ), + patch( + "homeassistant.components.stips_iru1.config_flow.async_fetch_catalog_devices", + new=AsyncMock(return_value=([], [])), + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == "form" + + +async def test_unique_id_includes_api_host(hass: HomeAssistant) -> None: + """Test same username on different API hosts can be configured separately.""" + existing_entry: MockConfigEntry = MockConfigEntry( + domain=DOMAIN, + unique_id=f"{DOMAIN}_stips.api.staging.visionalization.net_demo", + data={"api_host": "stips.api.staging.visionalization.net", "username": "demo"}, + ) + existing_entry.add_to_hass(hass) + + user_input = { + "api_host": "stips.api.prod.visionalization.net", + "username": "demo", + "password": "secret", + } + + with ( + patch( + "homeassistant.components.stips_iru1.config_flow.StipsApiClient.login", + new=AsyncMock(return_value=None), + ), + patch( + "homeassistant.components.stips_iru1.config_flow.StipsApiClient.get_areas", + new=AsyncMock(return_value=[{"id": 1, "name": "Home"}]), + ), + patch( + "homeassistant.components.stips_iru1.config_flow.async_fetch_catalog_devices", + new=AsyncMock(return_value=([], [{"uniqueName": "stips-iru1-123456"}])), + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == "create_entry" + + +async def test_duplicate_configuration_aborts(hass: HomeAssistant) -> None: + """Test duplicate host/username combinations abort.""" + existing_entry: MockConfigEntry = MockConfigEntry( + domain=DOMAIN, + unique_id=f"{DOMAIN}_stips.api.staging.visionalization.net_demo", + data={"api_host": "stips.api.staging.visionalization.net", "username": "demo"}, + ) + existing_entry.add_to_hass(hass) + + user_input = { + "api_host": "stips.api.staging.visionalization.net", + "username": "demo", + "password": "secret", + } + + with ( + patch( + "homeassistant.components.stips_iru1.config_flow.StipsApiClient.login", + new=AsyncMock(return_value=None), + ), + patch( + "homeassistant.components.stips_iru1.config_flow.StipsApiClient.get_areas", + new=AsyncMock(return_value=[{"id": 1, "name": "Home"}]), + ), + patch( + "homeassistant.components.stips_iru1.config_flow.async_fetch_catalog_devices", + new=AsyncMock(return_value=([], [{"uniqueName": "stips-iru1-123456"}])), + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" diff --git a/tests/components/stips_iru1/test_const.py b/tests/components/stips_iru1/test_const.py new file mode 100644 index 00000000000000..a2fe4955eabd02 --- /dev/null +++ b/tests/components/stips_iru1/test_const.py @@ -0,0 +1,53 @@ +"""Tests for stips_iru1 constants and helpers.""" + +from homeassistant.components.stips_iru1 import const + + +class TestRemoteType: + """Tests for remote type helpers.""" + + def test_is_protocol_ac(self) -> None: + """Test protocol AC detection.""" + assert const.is_protocol_ac("AC") is True + assert const.is_protocol_ac("ac") is True + assert const.is_protocol_ac(" AC ") is True + assert const.is_protocol_ac("LearnedAc") is False + assert const.is_protocol_ac("TV") is False + assert const.is_protocol_ac("") is False + assert const.is_protocol_ac(None) is False + + def test_is_learned_ac(self) -> None: + """Test learned AC detection.""" + assert const.is_learned_ac("LearnedAc") is True + assert const.is_learned_ac("learnedac") is True + assert const.is_learned_ac(" LearnedAc ") is True + assert const.is_learned_ac("AC") is False + assert const.is_learned_ac("LearnedTV") is False + assert const.is_learned_ac("") is False + assert const.is_learned_ac(None) is False + + def test_remote_uses_signal_buttons(self) -> None: + """Test signal button remote detection.""" + # LearnedAc should NOT have signal buttons (uses climate only) + assert const.remote_uses_signal_buttons("LearnedAc") is False + + # AC should NOT have signal buttons (uses protocol only) + assert const.remote_uses_signal_buttons("AC") is False + + # LearnedTV should have signal buttons + assert const.remote_uses_signal_buttons("LearnedTV") is True + assert const.remote_uses_signal_buttons("LearnedFan") is True + + # Unknown types should have signal buttons + assert const.remote_uses_signal_buttons("Unknown") is True + assert const.remote_uses_signal_buttons("") is True + assert const.remote_uses_signal_buttons(None) is True + + def test_normalize_remote_type(self) -> None: + """Test remote type normalization.""" + assert const.normalize_remote_type("AC") == "ac" + assert const.normalize_remote_type("LearnedAc") == "learnedac" + assert const.normalize_remote_type(" AC ") == "ac" + assert const.normalize_remote_type("Learned TV") == "learnedtv" + assert const.normalize_remote_type("") == "" + assert const.normalize_remote_type(None) == "" diff --git a/tests/components/stips_iru1/test_init.py b/tests/components/stips_iru1/test_init.py new file mode 100644 index 00000000000000..0fd188679c6265 --- /dev/null +++ b/tests/components/stips_iru1/test_init.py @@ -0,0 +1,122 @@ +"""Init tests for stips_iru1.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.stips_iru1 import ( + DOMAIN, + StipsIru1RuntimeData, + _register_catalog_devices, + async_setup_entry, + async_unload_entry, +) +from homeassistant.components.stips_iru1.const import PLATFORMS +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + + +async def test_async_setup_entry_forwards_only_climate( + hass: HomeAssistant, +) -> None: + """Test integration forwards only the climate platform for initial core PR scope.""" + entry: MockConfigEntry = MockConfigEntry( + domain=DOMAIN, + data={ + "devices": [ + { + "uniqueName": "stips-iru1-98eea1", + "name": "IR maze", + "remotes": [], + } + ] + }, + ) + entry.add_to_hass(hass) + + with patch.object( + hass.config_entries, + "async_forward_entry_setups", + return_value=True, + ) as mock_forward: + assert await hass.config_entries.async_setup(entry.entry_id) + + mock_forward.assert_called_once_with(entry, PLATFORMS) + assert PLATFORMS == [Platform.CLIMATE] + assert isinstance(entry.runtime_data, StipsIru1RuntimeData) + assert entry.runtime_data.devices == entry.data["devices"] + + +async def test_async_setup_entry_raises_for_invalid_devices_data( + hass: HomeAssistant, +) -> None: + """Test setup raises a config entry error when devices data is invalid.""" + entry: MockConfigEntry = MockConfigEntry( + domain=DOMAIN, data={"devices": {"invalid": "shape"}} + ) + + with ( + patch.object( + hass.config_entries, + "async_forward_entry_setups", + return_value=True, + ) as mock_forward, + pytest.raises(ConfigEntryError), + ): + await async_setup_entry(hass, entry) + + mock_forward.assert_not_called() + + +def test_register_catalog_devices_creates_device_entries(hass: HomeAssistant) -> None: + """Test catalog devices are registered with metadata.""" + entry: MockConfigEntry = MockConfigEntry( + domain=DOMAIN, + data={ + "devices": [ + { + "uniqueName": "stips-iru1-98eea1", + "name": "Living Room IR", + "buildVersion": "1.2.3", + "areaName": "Living Room", + }, + { + "name": "Missing unique name", + }, + ] + }, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.stips_iru1.normalize_device_mac", + return_value="AA:BB:CC:DD:EE:FF", + ): + _register_catalog_devices(hass, entry) + + registry = dr.async_get(hass) + device = registry.async_get_device(identifiers={(DOMAIN, "stips-iru1-98eea1")}) + + assert device is not None + assert device.name == "Living Room IR" + assert device.sw_version == "1.2.3" + assert device.suggested_area == "Living Room" + assert (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff") in device.connections + + +async def test_async_unload_entry_unloads_platforms(hass: HomeAssistant) -> None: + """Test unload delegates to platform unloading.""" + entry: MockConfigEntry = MockConfigEntry(domain=DOMAIN, data={"devices": []}) + + with patch.object( + hass.config_entries, + "async_unload_platforms", + return_value=True, + ) as mock_unload: + assert await async_unload_entry(hass, entry) is True + + mock_unload.assert_called_once_with(entry, PLATFORMS) diff --git a/tests/components/stips_iru1/test_local_http.py b/tests/components/stips_iru1/test_local_http.py new file mode 100644 index 00000000000000..eba8cb6b1b9eea --- /dev/null +++ b/tests/components/stips_iru1/test_local_http.py @@ -0,0 +1,305 @@ +"""Tests for STIPS local HTTP host resolution helpers.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import aiohttp + +from homeassistant.components.stips_iru1 import local_http +from homeassistant.components.stips_iru1.local_http import ( + _is_valid_catalog_hostname, + async_fetch_device_info_live_ip, +) +from homeassistant.core import HomeAssistant + + +def test_iter_dns_host_candidates_rejects_invalid_catalog_host() -> None: + """Invalid cloud-provided unique names should not become request hosts.""" + hosts = local_http.iter_dns_host_candidates("http://evil/path", "10.0.0.42") + + assert hosts == ["10.0.0.42"] + + +def test_iter_dns_host_candidates_accepts_valid_catalog_host() -> None: + """Valid unique-name host should produce DNS-first candidates.""" + hosts = local_http.iter_dns_host_candidates("stips-iru1-98eea1", "") + + assert hosts == ["stips-iru1-98eea1", "stips-iru1-98eea1.local"] + + +async def test_async_build_control_hosts_uses_short_ttl_cache( + hass: HomeAssistant, +) -> None: + """Second lookup with same key should use cache without probing again.""" + local_http._HOST_CACHE.clear() + + with patch( + "homeassistant.components.stips_iru1.local_http.async_fetch_device_info_live_ip", + new=AsyncMock(return_value="10.0.0.123"), + ) as mock_probe: + first_hosts, first_live_ip = await local_http.async_build_control_hosts( + hass, + device_unique_name="stips-iru1-98eea1", + backend_ip="", + ) + second_hosts, second_live_ip = await local_http.async_build_control_hosts( + hass, + device_unique_name="stips-iru1-98eea1", + backend_ip="", + ) + + assert first_live_ip == "10.0.0.123" + assert second_live_ip == "10.0.0.123" + assert first_hosts == second_hosts + assert mock_probe.await_count == 1 + + +async def test_async_build_control_hosts_returns_empty_when_no_candidates( + hass: HomeAssistant, +) -> None: + """Invalid host and backend IP should yield no control hosts.""" + local_http._HOST_CACHE.clear() + + hosts, live_ip = await local_http.async_build_control_hosts( + hass, + device_unique_name="http://evil/path", + backend_ip="not-an-ip", + ) + + assert hosts == [] + assert live_ip == "" + + +async def test_async_build_control_hosts_appends_discovered_live_ip( + hass: HomeAssistant, +) -> None: + """Discovered live IP should be appended after DNS candidates.""" + local_http._HOST_CACHE.clear() + + with patch( + "homeassistant.components.stips_iru1.local_http.async_fetch_device_info_live_ip", + new=AsyncMock(return_value="10.0.0.123"), + ): + hosts, live_ip = await local_http.async_build_control_hosts( + hass, + device_unique_name="stips-iru1-98eea1", + backend_ip="", + ) + + assert hosts == ["stips-iru1-98eea1", "stips-iru1-98eea1.local", "10.0.0.123"] + assert live_ip == "10.0.0.123" + + +def test_is_valid_catalog_hostname() -> None: + """Test hostname validation.""" + assert _is_valid_catalog_hostname("stips-iru1-98eea1") is True + assert _is_valid_catalog_hostname("stips.iru1.local") is True + assert _is_valid_catalog_hostname("stips iru1") is False + assert _is_valid_catalog_hostname("stips..iru1") is False + assert _is_valid_catalog_hostname("http://evil.com") is False + assert _is_valid_catalog_hostname("host:port") is False + assert _is_valid_catalog_hostname("10.0.0.1") is False # IP is invalid + assert _is_valid_catalog_hostname("") is False + assert _is_valid_catalog_hostname(" ") is False + + +def test_extract_live_ip_skips_invalid_values() -> None: + """Live IP extraction should skip blank and malformed candidate values.""" + assert ( + local_http._extract_live_ip( + { + "ip_address": " ", + "ipAddress": "not-an-ip", + "Ip": "10.0.0.126", + } + ) + == "10.0.0.126" + ) + assert ( + local_http._extract_live_ip( + { + "ip_address": " ", + "ipAddress": "not-an-ip", + } + ) + == "" + ) + + +async def test_async_fetch_device_info_live_ip_success(hass: HomeAssistant) -> None: + """Test fetching live IP from device info endpoint.""" + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.json = AsyncMock(return_value={"ip_address": "10.0.0.123"}) + context = AsyncMock() + context.__aenter__.return_value = mock_response + context.__aexit__.return_value = None + + with patch( + "homeassistant.components.stips_iru1.local_http.async_get_clientsession" + ) as mock_session: + mock_session.return_value.get = MagicMock(return_value=context) + + timeout = aiohttp.ClientTimeout(total=2) + ip = await async_fetch_device_info_live_ip(hass, host="test", timeout=timeout) + assert ip == "10.0.0.123" + + +async def test_async_fetch_device_info_live_ip_timeout( + hass: HomeAssistant, +) -> None: + """Test fetch handles timeout.""" + with patch( + "homeassistant.components.stips_iru1.local_http.async_get_clientsession" + ) as mock_session: + mock_session.return_value.get = MagicMock(side_effect=TimeoutError()) + + timeout = aiohttp.ClientTimeout(total=2) + ip = await async_fetch_device_info_live_ip(hass, host="test", timeout=timeout) + assert ip == "" + + +async def test_async_fetch_device_info_live_ip_http_error( + hass: HomeAssistant, +) -> None: + """Test fetch handles HTTP errors.""" + mock_response = AsyncMock() + mock_response.status = 500 + context = AsyncMock() + context.__aenter__.return_value = mock_response + context.__aexit__.return_value = None + + with patch( + "homeassistant.components.stips_iru1.local_http.async_get_clientsession" + ) as mock_session: + mock_session.return_value.get = MagicMock(return_value=context) + + timeout = aiohttp.ClientTimeout(total=2) + ip = await async_fetch_device_info_live_ip(hass, host="test", timeout=timeout) + assert ip == "" + + +async def test_async_fetch_device_info_live_ip_content_type_fallback( + hass: HomeAssistant, +) -> None: + """Test fallback to parsing response text as JSON.""" + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.json = AsyncMock( + side_effect=aiohttp.ContentTypeError(request_info=MagicMock(), history=()) + ) + mock_response.text = AsyncMock(return_value='{"ip": "10.0.0.124"}') + context = AsyncMock() + context.__aenter__.return_value = mock_response + context.__aexit__.return_value = None + + with patch( + "homeassistant.components.stips_iru1.local_http.async_get_clientsession" + ) as mock_session: + mock_session.return_value.get = MagicMock(return_value=context) + + timeout = aiohttp.ClientTimeout(total=2) + ip = await async_fetch_device_info_live_ip(hass, host="test", timeout=timeout) + assert ip == "10.0.0.124" + + +async def test_async_fetch_device_info_live_ip_content_type_invalid_json( + hass: HomeAssistant, +) -> None: + """Invalid JSON text should return an empty live IP.""" + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.json = AsyncMock( + side_effect=aiohttp.ContentTypeError(request_info=MagicMock(), history=()) + ) + mock_response.text = AsyncMock(return_value="not-json") + context = AsyncMock() + context.__aenter__.return_value = mock_response + context.__aexit__.return_value = None + + with patch( + "homeassistant.components.stips_iru1.local_http.async_get_clientsession" + ) as mock_session: + mock_session.return_value.get = MagicMock(return_value=context) + + timeout = aiohttp.ClientTimeout(total=2) + ip = await async_fetch_device_info_live_ip(hass, host="test", timeout=timeout) + assert ip == "" + + +async def test_async_fetch_device_info_live_ip_value_error_fallback( + hass: HomeAssistant, +) -> None: + """Test value errors also fall back to parsing response text as JSON.""" + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.json = AsyncMock(side_effect=ValueError()) + mock_response.text = AsyncMock(return_value='{"ipAddress": "10.0.0.125"}') + context = AsyncMock() + context.__aenter__.return_value = mock_response + context.__aexit__.return_value = None + + with patch( + "homeassistant.components.stips_iru1.local_http.async_get_clientsession" + ) as mock_session: + mock_session.return_value.get = MagicMock(return_value=context) + + timeout = aiohttp.ClientTimeout(total=2) + ip = await async_fetch_device_info_live_ip(hass, host="test", timeout=timeout) + assert ip == "10.0.0.125" + + +async def test_async_fetch_device_info_live_ip_value_error_invalid_json( + hass: HomeAssistant, +) -> None: + """Invalid JSON after a value error should return an empty live IP.""" + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.json = AsyncMock(side_effect=ValueError()) + mock_response.text = AsyncMock(return_value="not-json") + context = AsyncMock() + context.__aenter__.return_value = mock_response + context.__aexit__.return_value = None + + with patch( + "homeassistant.components.stips_iru1.local_http.async_get_clientsession" + ) as mock_session: + mock_session.return_value.get = MagicMock(return_value=context) + + timeout = aiohttp.ClientTimeout(total=2) + ip = await async_fetch_device_info_live_ip(hass, host="test", timeout=timeout) + assert ip == "" + + +async def test_async_fetch_device_info_live_ip_rejects_non_dict_payload( + hass: HomeAssistant, +) -> None: + """Non-dict JSON payloads should not be accepted as device info.""" + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.json = AsyncMock(return_value=["10.0.0.123"]) + context = AsyncMock() + context.__aenter__.return_value = mock_response + context.__aexit__.return_value = None + + with patch( + "homeassistant.components.stips_iru1.local_http.async_get_clientsession" + ) as mock_session: + mock_session.return_value.get = MagicMock(return_value=context) + + timeout = aiohttp.ClientTimeout(total=2) + ip = await async_fetch_device_info_live_ip(hass, host="test", timeout=timeout) + assert ip == "" + + +async def test_async_fetch_device_info_live_ip_client_error( + hass: HomeAssistant, +) -> None: + """Test client errors are handled.""" + with patch( + "homeassistant.components.stips_iru1.local_http.async_get_clientsession" + ) as mock_session: + mock_session.return_value.get = MagicMock(side_effect=aiohttp.ClientError()) + + timeout = aiohttp.ClientTimeout(total=2) + ip = await async_fetch_device_info_live_ip(hass, host="test", timeout=timeout) + assert ip == "" diff --git a/tests/helpers/template/test_init.py b/tests/helpers/template/test_init.py index 9b61f2a4d76c06..a1e5a2977d18f0 100644 --- a/tests/helpers/template/test_init.py +++ b/tests/helpers/template/test_init.py @@ -2056,6 +2056,42 @@ async def test_template_timeout(hass: HomeAssistant) -> None: assert await tmp5.async_render_will_timeout(0.000001) is True +async def test_template_timeout_raise_exc_race(hass: HomeAssistant) -> None: + """Test template timeout tolerates a thread finishing during raise_exc.""" + + class FakeThreadWithException: + """Fake timeout thread that reproduces the raise_exc race.""" + + joined = False + + def __init__(self, target) -> None: + """Initialize the fake thread.""" + self.target = target + + def start(self) -> None: + """Do not run the target so finish_event never fires.""" + + def is_alive(self) -> bool: + """Pretend the thread is still alive until interruption.""" + return True + + def raise_exc(self, exctype) -> None: + """Simulate the CPython async_raise race.""" + raise SystemError("PyThreadState_SetAsyncExc failed") + + def join(self) -> None: + """Track that cleanup still joins the fake thread.""" + type(self).joined = True + + with patch( + "homeassistant.helpers.template.ThreadWithException", FakeThreadWithException + ): + tmp = template.Template("{{ states | count }}", hass) + assert await tmp.async_render_will_timeout(0.000001) is True + + assert FakeThreadWithException.joined is True + + async def test_template_timeout_raise(hass: HomeAssistant) -> None: """Test we can raise from.""" tmp2 = template.Template("{{ error_invalid + 1 }}", hass)