diff --git a/.strict-typing b/.strict-typing index ba072005a3415f..40857cdca960a2 100644 --- a/.strict-typing +++ b/.strict-typing @@ -432,6 +432,7 @@ homeassistant.components.overseerr.* homeassistant.components.ovhcloud_ai_endpoints.* homeassistant.components.p1_monitor.* homeassistant.components.paj_gps.* +homeassistant.components.panasonic_window_ac_hk.* homeassistant.components.panel_custom.* homeassistant.components.paperless_ngx.* homeassistant.components.peblar.* diff --git a/CODEOWNERS b/CODEOWNERS index 87a4c2155aac1d..f0860dc8b440d0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1331,6 +1331,8 @@ CLAUDE.md @home-assistant/core /tests/components/paj_gps/ @skipperro /homeassistant/components/palazzetti/ @dotvav /tests/components/palazzetti/ @dotvav +/homeassistant/components/panasonic_window_ac_hk/ @sam0737 +/tests/components/panasonic_window_ac_hk/ @sam0737 /homeassistant/components/panel_custom/ @home-assistant/frontend /tests/components/panel_custom/ @home-assistant/frontend /homeassistant/components/paperless_ngx/ @fvgarrel diff --git a/homeassistant/brands/panasonic.json b/homeassistant/brands/panasonic.json index 2d8f29a3968392..68f78d84e231b1 100644 --- a/homeassistant/brands/panasonic.json +++ b/homeassistant/brands/panasonic.json @@ -1,5 +1,9 @@ { "domain": "panasonic", "name": "Panasonic", - "integrations": ["panasonic_bluray", "panasonic_viera"] + "integrations": [ + "panasonic_bluray", + "panasonic_viera", + "panasonic_window_ac_hk" + ] } diff --git a/homeassistant/components/panasonic_window_ac_hk/__init__.py b/homeassistant/components/panasonic_window_ac_hk/__init__.py new file mode 100644 index 00000000000000..568154c3edda34 --- /dev/null +++ b/homeassistant/components/panasonic_window_ac_hk/__init__.py @@ -0,0 +1,62 @@ +"""The Panasonic Window Air Conditioner (Hong Kong/Macau) integration. + +Controls Panasonic window / through-the-wall air conditioners sold in Hong Kong +and Macau (CW-HU / CW-HZ / CW-SU / CW-SUL families) over the Home Assistant +``infrared`` platform. Reverse-engineered and verified on a CW-HU70ZA. +""" + +from dataclasses import dataclass + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import ( + CONF_INFRARED_EMITTER_ENTITY_ID, + DEFAULT_FAN, + DEFAULT_MODE, + DEFAULT_SWING, + DEFAULT_TEMP, +) + +PLATFORMS = [Platform.BUTTON, Platform.CLIMATE, Platform.SWITCH] + + +@dataclass +class PanasonicWindowAcHKRuntimeData: + """Shared assumed state for one air conditioner. + + Infrared is one-way, so the state cannot be read back from the unit. The + climate entity and the nanoeX switch share this object because nanoeX lives + inside the full state frame and toggling it must re-assert mode, temperature, + fan and swing. + """ + + infrared_emitter_entity_id: str + power: bool = False + mode: str = DEFAULT_MODE + temp: float = DEFAULT_TEMP + fan: str = DEFAULT_FAN + swing: str = DEFAULT_SWING + nanoex: bool = False + + +type PanasonicWindowAcHKConfigEntry = ConfigEntry[PanasonicWindowAcHKRuntimeData] + + +async def async_setup_entry( + hass: HomeAssistant, entry: PanasonicWindowAcHKConfigEntry +) -> bool: + """Set up a Panasonic window air conditioner from a config entry.""" + entry.runtime_data = PanasonicWindowAcHKRuntimeData( + infrared_emitter_entity_id=entry.data[CONF_INFRARED_EMITTER_ENTITY_ID], + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: PanasonicWindowAcHKConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/panasonic_window_ac_hk/button.py b/homeassistant/components/panasonic_window_ac_hk/button.py new file mode 100644 index 00000000000000..1b728c08e18e83 --- /dev/null +++ b/homeassistant/components/panasonic_window_ac_hk/button.py @@ -0,0 +1,65 @@ +"""Button platform for the Panasonic Window A/C (Hong Kong/Macau). + +Quiet and Powerful are momentary toggles on the unit. They are sent as dedicated +short frames and carry no mode/temperature/fan/swing, so they are stateless +buttons rather than part of the climate entity. +""" + +from dataclasses import dataclass + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import PanasonicWindowAcHKConfigEntry +from .entity import PanasonicWindowAcHKEntity + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class PanasonicWindowAcHKButtonEntityDescription(ButtonEntityDescription): + """Describes a Panasonic window A/C toggle button.""" + + short_frame_kind: str + + +BUTTON_DESCRIPTIONS: tuple[PanasonicWindowAcHKButtonEntityDescription, ...] = ( + PanasonicWindowAcHKButtonEntityDescription( + key="quiet", translation_key="quiet", short_frame_kind="quiet" + ), + PanasonicWindowAcHKButtonEntityDescription( + key="powerful", translation_key="powerful", short_frame_kind="powerful" + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PanasonicWindowAcHKConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Quiet and Powerful buttons for one air conditioner.""" + async_add_entities( + PanasonicWindowAcHKToggleButton(entry, description) + for description in BUTTON_DESCRIPTIONS + ) + + +class PanasonicWindowAcHKToggleButton(PanasonicWindowAcHKEntity, ButtonEntity): + """A momentary Quiet/Powerful toggle (short frame).""" + + entity_description: PanasonicWindowAcHKButtonEntityDescription + + def __init__( + self, + entry: PanasonicWindowAcHKConfigEntry, + description: PanasonicWindowAcHKButtonEntityDescription, + ) -> None: + """Initialize the toggle button.""" + super().__init__(entry, description.key) + self.entity_description = description + + async def async_press(self) -> None: + """Send the Quiet/Powerful toggle frame.""" + await self._async_send_short(self.entity_description.short_frame_kind) diff --git a/homeassistant/components/panasonic_window_ac_hk/climate.py b/homeassistant/components/panasonic_window_ac_hk/climate.py new file mode 100644 index 00000000000000..24f65a6d510ec9 --- /dev/null +++ b/homeassistant/components/panasonic_window_ac_hk/climate.py @@ -0,0 +1,145 @@ +"""Climate platform for the Panasonic Window A/C (Hong Kong/Macau).""" + +from typing import Any + +from homeassistant.components.climate import ( + ATTR_FAN_MODE, + ATTR_SWING_MODE, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity + +from . import PanasonicWindowAcHKConfigEntry +from .const import FAN_MODES, SWING_MODES +from .encoder import MAX_TEMP, MIN_TEMP +from .entity import PanasonicWindowAcHKEntity + +PARALLEL_UPDATES = 1 + +_MODE_TO_HVAC = { + "auto": HVACMode.AUTO, + "cool": HVACMode.COOL, + "dry": HVACMode.DRY, + "heat": HVACMode.HEAT, +} +_HVAC_TO_MODE = {hvac: mode for mode, hvac in _MODE_TO_HVAC.items()} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PanasonicWindowAcHKConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the climate entity for one air conditioner.""" + async_add_entities([PanasonicWindowAcHKClimate(entry)]) + + +class PanasonicWindowAcHKClimate( + PanasonicWindowAcHKEntity, ClimateEntity, RestoreEntity +): + """Optimistic climate control for one air conditioner (infrared is one-way).""" + + _attr_name = None + _attr_icon = "mdi:air-conditioner" + _attr_assumed_state = True + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_target_temperature_step = 0.5 + _attr_min_temp = MIN_TEMP + _attr_max_temp = MAX_TEMP + _attr_hvac_modes = [ + HVACMode.OFF, + HVACMode.AUTO, + HVACMode.COOL, + HVACMode.DRY, + HVACMode.HEAT, + ] + _attr_fan_modes = FAN_MODES + _attr_swing_modes = SWING_MODES + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + ) + + def __init__(self, entry: PanasonicWindowAcHKConfigEntry) -> None: + """Initialize the climate entity.""" + super().__init__(entry, "climate") + + async def async_added_to_hass(self) -> None: + """Restore the last assumed state across restarts (infrared is one-way).""" + await super().async_added_to_hass() + if (last_state := await self.async_get_last_state()) is None: + return + data = self._runtime_data + if last_state.state == HVACMode.OFF: + data.power = False + elif last_state.state in _HVAC_TO_MODE: + data.power = True + data.mode = _HVAC_TO_MODE[HVACMode(last_state.state)] + if (temp := last_state.attributes.get(ATTR_TEMPERATURE)) is not None: + data.temp = float(temp) + if (fan := last_state.attributes.get(ATTR_FAN_MODE)) in FAN_MODES: + data.fan = fan + if (swing := last_state.attributes.get(ATTR_SWING_MODE)) in SWING_MODES: + data.swing = swing + + @property + def hvac_mode(self) -> HVACMode: + """Return the current HVAC mode (OFF when powered off).""" + if not self._runtime_data.power: + return HVACMode.OFF + return _MODE_TO_HVAC[self._runtime_data.mode] + + @property + def target_temperature(self) -> float: + """Return the current target temperature.""" + return self._runtime_data.temp + + @property + def fan_mode(self) -> str: + """Return the current fan mode.""" + return self._runtime_data.fan + + @property + def swing_mode(self) -> str: + """Return the current swing mode.""" + return self._runtime_data.swing + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the HVAC mode (or power off).""" + if hvac_mode is HVACMode.OFF: + self._runtime_data.power = False + else: + self._runtime_data.power = True + self._runtime_data.mode = _HVAC_TO_MODE[hvac_mode] + await self._async_send_full() + self.async_write_ha_state() + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the target temperature (0.5 degree steps).""" + temperature = kwargs[ATTR_TEMPERATURE] + self._runtime_data.temp = temperature + if self._runtime_data.power: + await self._async_send_full() + self.async_write_ha_state() + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set the fan speed.""" + self._runtime_data.fan = fan_mode + if self._runtime_data.power: + await self._async_send_full() + self.async_write_ha_state() + + async def async_set_swing_mode(self, swing_mode: str) -> None: + """Set the swing position.""" + self._runtime_data.swing = swing_mode + if self._runtime_data.power: + await self._async_send_full() + self.async_write_ha_state() diff --git a/homeassistant/components/panasonic_window_ac_hk/command.py b/homeassistant/components/panasonic_window_ac_hk/command.py new file mode 100644 index 00000000000000..c756fb520560cb --- /dev/null +++ b/homeassistant/components/panasonic_window_ac_hk/command.py @@ -0,0 +1,47 @@ +"""Wrap the pure-Python encoder as an infrared-protocols Command. + +The Home Assistant ``infrared`` platform sends ``infrared_protocols.Command`` +objects to emitters, which read ``command.modulation`` (carrier Hz) and +``command.get_raw_timings()`` (signed microsecond list). This adapter exposes +our Panasonic CW frames in that shape. +""" + +from infrared_protocols.commands import Command + +from . import encoder + + +class PanasonicWindowAcHKCommand(Command): # type: ignore[misc] + """An IR command for a Panasonic HK/Macau window A/C (CW-HU/HZ/SU/SUL).""" + + def __init__(self, state: list[int]) -> None: + """Wrap a pre-built 27-byte full frame or 16-byte short frame.""" + super().__init__(modulation=encoder.CARRIER_HZ, repeat_count=0) + self._state = state + + @classmethod + def full( + cls, + *, + off: bool = False, + mode: str, + temp: float, + fan: str, + swing: str, + nanoex: bool, + ) -> PanasonicWindowAcHKCommand: + """Build a full state command (power/mode/temp/fan/swing/nanoeX).""" + return cls( + encoder.build_full_frame( + off=off, mode=mode, temp=temp, fan=fan, swing=swing, nanoex=nanoex + ) + ) + + @classmethod + def short(cls, kind: str) -> PanasonicWindowAcHKCommand: + """Build a Quiet/Powerful toggle command.""" + return cls(encoder.build_short_frame(kind)) + + def get_raw_timings(self) -> list[int]: + """Return signed microsecond timings (positive pulse, negative space).""" + return encoder.frame_to_timings(self._state) diff --git a/homeassistant/components/panasonic_window_ac_hk/config_flow.py b/homeassistant/components/panasonic_window_ac_hk/config_flow.py new file mode 100644 index 00000000000000..c102220bd1060a --- /dev/null +++ b/homeassistant/components/panasonic_window_ac_hk/config_flow.py @@ -0,0 +1,47 @@ +"""Config flow for the Panasonic Window A/C (Hong Kong/Macau).""" + +from typing import Any + +import voluptuous as vol + +from homeassistant.components.infrared import ( + DOMAIN as INFRARED_DOMAIN, + async_get_emitters, +) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers.selector import EntitySelector, EntitySelectorConfig + +from .const import CONF_INFRARED_EMITTER_ENTITY_ID, DEVICE_NAME, DOMAIN + + +class PanasonicWindowAcHKConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for one Panasonic window air conditioner.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + emitter_entity_ids = async_get_emitters(self.hass) + if not emitter_entity_ids: + return self.async_abort(reason="no_emitters") + + if user_input is not None: + await self.async_set_unique_id(user_input[CONF_INFRARED_EMITTER_ENTITY_ID]) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=DEVICE_NAME, data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_INFRARED_EMITTER_ENTITY_ID): EntitySelector( + EntitySelectorConfig( + domain=INFRARED_DOMAIN, + include_entities=emitter_entity_ids, + ) + ), + } + ), + ) diff --git a/homeassistant/components/panasonic_window_ac_hk/const.py b/homeassistant/components/panasonic_window_ac_hk/const.py new file mode 100644 index 00000000000000..cc4e3023824261 --- /dev/null +++ b/homeassistant/components/panasonic_window_ac_hk/const.py @@ -0,0 +1,18 @@ +"""Constants for the Panasonic Window A/C (Hong Kong/Macau) integration.""" + +DOMAIN = "panasonic_window_ac_hk" + +DEVICE_NAME = "Panasonic Window AC (Hong Kong)" + +CONF_INFRARED_EMITTER_ENTITY_ID = "infrared_emitter_entity_id" + +# Default assumed state for a freshly added unit. +DEFAULT_MODE = "cool" +DEFAULT_TEMP = 24.0 +DEFAULT_FAN = "auto" +DEFAULT_SWING = "auto" + +# Fan speeds (must match the encoder's FAN_NIBBLE keys). +FAN_MODES = ["auto", "low", "mediumLow", "medium", "mediumHigh", "high"] +# Swing positions (must match the encoder's SWING_NIBBLE keys). +SWING_MODES = ["auto", "fixed"] diff --git a/homeassistant/components/panasonic_window_ac_hk/encoder.py b/homeassistant/components/panasonic_window_ac_hk/encoder.py new file mode 100644 index 00000000000000..da7371148da60c --- /dev/null +++ b/homeassistant/components/panasonic_window_ac_hk/encoder.py @@ -0,0 +1,152 @@ +"""Panasonic window A/C infrared encoder (Hong Kong / Macau CW-HU/HZ/SU/SUL). + +Pure-Python, no Home Assistant dependencies, so it can be unit-tested in +isolation and (eventually) contributed to the infrared-protocols library. + +Implements the reverse-engineered Panasonic CW-series protocol (see the +integration documentation on home-assistant.io). The 27-byte full state frame +and the 16-byte Quiet/Powerful short frames are both produced here as +protocol-agnostic microsecond timings: a flat ``list[int]`` where a positive +value is a pulse (carrier on) and a negative value is a space (carrier off), +matching the Home Assistant ``infrared`` platform convention. +""" + +# --- Physical-layer timings, in microseconds (canonical Panasonic values) ---- +HEADER_MARK = 3456 +HEADER_SPACE = 1728 +BIT_MARK = 432 +ZERO_SPACE = 432 +ONE_SPACE = 1296 +SECTION_GAP = 10000 # between Frame 1 and Frame 2 (~10 ms) +MESSAGE_GAP = 100000 # trailing, after Frame 2 (~100 ms) + +CARRIER_HZ = 38000 + +# --- Semantic field maps (from the spec) ------------------------------------ +MODE_NIBBLE = {"auto": 0x0, "dry": 0x2, "cool": 0x3, "heat": 0x4} +FAN_NIBBLE = { + "auto": 0xA, + "low": 0x3, + "mediumLow": 0x4, + "medium": 0x5, + "mediumHigh": 0x6, + "high": 0x7, +} +SWING_NIBBLE = {"auto": 0xF, "fixed": 0x5} + +MIN_TEMP = 16 +MAX_TEMP = 30 + +NANOEX_BYTE = 25 +NANOEX_MASK = 0x04 + +# Short-frame command payloads (bytes 12..14), keyed by toggle kind. +_SHORT_PAYLOAD = { + "quiet": [0x80, 0x81, 0x33], + "powerful": [0x80, 0x86, 0x35], +} + + +def checksum(state: list[int], start: int, end: int) -> int: + """Sum bytes ``state[start..end]`` (inclusive) modulo 256.""" + total = 0 + for i in range(start, end + 1): + total = (total + state[i]) & 0xFF + return total + + +def build_full_frame( + *, + off: bool = False, + mode: str, + temp: float, + fan: str, + swing: str, + nanoex: bool, +) -> list[int]: + """Build the 27-byte full state frame from semantic parameters. + + ``temp`` is in degrees Celsius; byte 14 stores ``round(temp * 2)`` so the + protocol's 0.5 C step is preserved. + """ + state = [0] * 27 + # Frame 1 (constant preamble). + for i, value in enumerate([0x02, 0x20, 0xE0, 0x04, 0x00, 0x00, 0x00, 0x06]): + state[i] = value + # Frame 2. + state[8] = 0x02 + state[9] = 0x20 + state[10] = 0xE0 + state[11] = 0x04 + state[12] = 0x00 + state[13] = (MODE_NIBBLE[mode] << 4) | (0 if off else 1) + state[14] = round(temp * 2) + state[15] = 0x80 + state[16] = (FAN_NIBBLE[fan] << 4) | SWING_NIBBLE[swing] + state[17] = 0x0D + state[18] = 0x00 + state[19] = 0x0E + state[20] = 0xE0 + state[21] = 0x00 + state[22] = 0x00 + state[23] = 0x81 + state[24] = 0x00 + state[25] = 0x02 | (NANOEX_MASK if nanoex else 0x00) + state[26] = checksum(state, 8, 25) + return state + + +def build_short_frame(kind: str) -> list[int]: + """Build the 16-byte Quiet/Powerful toggle frame (no mode/temp/fan/swing).""" + try: + payload = _SHORT_PAYLOAD[kind] + except KeyError: + raise ValueError(f"unknown short-frame kind: {kind!r}") from None + state = [ + 0x02, + 0x20, + 0xE0, + 0x04, + 0x00, + 0x00, + 0x00, + 0x06, # Frame 1 + 0x02, + 0x20, + 0xE0, + 0x04, # Frame 2 magic + *payload, + ] + state.append(checksum(state, 8, 14)) + return state + + +def _bits_lsb(byte: int) -> list[int]: + """Return the 8 bits of ``byte``, least-significant first.""" + return [(byte >> j) & 1 for j in range(8)] + + +def _frame_timings(frame: list[int], trailing_gap: int) -> list[int]: + """Encode one frame (header + LSB-first bits + trailing mark/gap).""" + timings = [HEADER_MARK, -HEADER_SPACE] + for byte in frame: + for bit in _bits_lsb(byte): + timings.append(BIT_MARK) + timings.append(-(ONE_SPACE if bit else ZERO_SPACE)) + timings.append(BIT_MARK) + timings.append(-trailing_gap) + return timings + + +def frame_to_timings(state: list[int]) -> list[int]: + """Convert a state byte list into signed microsecond timings. + + Frame 1 is the first 8 bytes; Frame 2 is the remainder. A 27-byte full + frame and a 16-byte short frame both work (the split is always at byte 8). + """ + frame1 = state[:8] + frame2 = state[8:] + return [ + *_frame_timings(frame1, SECTION_GAP), + *_frame_timings(frame2, MESSAGE_GAP), + ] diff --git a/homeassistant/components/panasonic_window_ac_hk/entity.py b/homeassistant/components/panasonic_window_ac_hk/entity.py new file mode 100644 index 00000000000000..dda6b40a171652 --- /dev/null +++ b/homeassistant/components/panasonic_window_ac_hk/entity.py @@ -0,0 +1,46 @@ +"""Base entity for the Panasonic Window A/C (Hong Kong/Macau) integration.""" + +from homeassistant.components.infrared import InfraredEmitterConsumerEntity +from homeassistant.helpers.device_registry import DeviceInfo + +from . import PanasonicWindowAcHKConfigEntry +from .command import PanasonicWindowAcHKCommand +from .const import DOMAIN + + +class PanasonicWindowAcHKEntity(InfraredEmitterConsumerEntity): + """Base entity sharing one air conditioner's assumed state and emitter.""" + + _attr_has_entity_name = True + + def __init__( + self, entry: PanasonicWindowAcHKConfigEntry, unique_id_suffix: str + ) -> None: + """Initialize the entity from a config entry.""" + self._runtime_data = entry.runtime_data + self._infrared_emitter_entity_id = entry.runtime_data.infrared_emitter_entity_id + self._attr_unique_id = f"{entry.entry_id}_{unique_id_suffix}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name=entry.title, + manufacturer="Panasonic", + model="Window-Type A/C (CW-HU/HZ/SU/SUL, Hong Kong/Macau)", + ) + + async def _async_send_full(self) -> None: + """Send the current full state frame through the infrared emitter.""" + data = self._runtime_data + await self._send_command( + PanasonicWindowAcHKCommand.full( + off=not data.power, + mode=data.mode, + temp=data.temp, + fan=data.fan, + swing=data.swing, + nanoex=data.nanoex, + ) + ) + + async def _async_send_short(self, kind: str) -> None: + """Send a Quiet/Powerful short toggle frame.""" + await self._send_command(PanasonicWindowAcHKCommand.short(kind)) diff --git a/homeassistant/components/panasonic_window_ac_hk/icons.json b/homeassistant/components/panasonic_window_ac_hk/icons.json new file mode 100644 index 00000000000000..816365dc5eceb2 --- /dev/null +++ b/homeassistant/components/panasonic_window_ac_hk/icons.json @@ -0,0 +1,17 @@ +{ + "entity": { + "button": { + "powerful": { + "default": "mdi:rocket-launch" + }, + "quiet": { + "default": "mdi:volume-mute" + } + }, + "switch": { + "nanoex": { + "default": "mdi:air-purifier" + } + } + } +} diff --git a/homeassistant/components/panasonic_window_ac_hk/manifest.json b/homeassistant/components/panasonic_window_ac_hk/manifest.json new file mode 100644 index 00000000000000..fbb6ca7bbc0457 --- /dev/null +++ b/homeassistant/components/panasonic_window_ac_hk/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "panasonic_window_ac_hk", + "name": "Panasonic Window Air Conditioner (Hong Kong/Macau)", + "codeowners": ["@sam0737"], + "config_flow": true, + "dependencies": ["infrared"], + "documentation": "https://www.home-assistant.io/integrations/panasonic_window_ac_hk", + "integration_type": "device", + "iot_class": "assumed_state", + "quality_scale": "bronze" +} diff --git a/homeassistant/components/panasonic_window_ac_hk/quality_scale.yaml b/homeassistant/components/panasonic_window_ac_hk/quality_scale.yaml new file mode 100644 index 00000000000000..bfbcff92d4fdbe --- /dev/null +++ b/homeassistant/components/panasonic_window_ac_hk/quality_scale.yaml @@ -0,0 +1,116 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: | + This integration does not poll; infrared is one-way and state is assumed. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + 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: + status: exempt + comment: | + This integration only proxies commands through an existing infrared + emitter entity. There is no separate device connection or credentials to + validate during the config flow; the flow aborts when no emitter exists. + test-before-setup: + status: exempt + comment: | + This integration only proxies commands through an existing infrared + entity, so there is no separate connection to validate during setup. + unique-config-entry: done + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not register custom actions. + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: done + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + This integration does not support discovery. + discovery: + status: exempt + comment: | + This integration is configured manually via config flow. + docs-data-update: + status: exempt + comment: | + Infrared is one-way; this integration does not fetch data from devices. + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + Each config entry creates a single device. + entity-category: done + entity-device-class: + status: exempt + comment: | + No device classes apply to the climate, nanoeX switch or + Quiet/Powerful button entities. + entity-disabled-by-default: + status: exempt + comment: | + No entities should be disabled by default. + entity-translations: done + exception-translations: + status: exempt + comment: | + This integration does not raise exceptions. + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + This integration does not have repairable issues. + stale-devices: + status: exempt + comment: | + Each config entry manages exactly one device. + + # Platinum + async-dependency: + status: exempt + comment: | + This integration has no external async dependencies. + inject-websession: + status: exempt + comment: | + This integration does not make HTTP requests. + strict-typing: done diff --git a/homeassistant/components/panasonic_window_ac_hk/strings.json b/homeassistant/components/panasonic_window_ac_hk/strings.json new file mode 100644 index 00000000000000..c9fd41c80033f4 --- /dev/null +++ b/homeassistant/components/panasonic_window_ac_hk/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "This air conditioner is already configured with this infrared transmitter.", + "no_emitters": "No infrared transmitter entities found. Please set up an infrared device first." + }, + "step": { + "user": { + "data": { + "infrared_emitter_entity_id": "Infrared transmitter" + }, + "data_description": { + "infrared_emitter_entity_id": "The infrared transmitter entity used to send commands." + }, + "description": "Set up a Panasonic window air conditioner sold in Hong Kong or Macau (CW-HU, CW-HZ, CW-SU and CW-SUL series). Choose the infrared transmitter that should send the commands.", + "title": "Panasonic Window Air Conditioner" + } + } + }, + "entity": { + "button": { + "powerful": { + "name": "Powerful" + }, + "quiet": { + "name": "Quiet" + } + }, + "switch": { + "nanoex": { + "name": "nanoeX" + } + } + } +} diff --git a/homeassistant/components/panasonic_window_ac_hk/switch.py b/homeassistant/components/panasonic_window_ac_hk/switch.py new file mode 100644 index 00000000000000..0a1ea289d69ac1 --- /dev/null +++ b/homeassistant/components/panasonic_window_ac_hk/switch.py @@ -0,0 +1,64 @@ +"""Switch platform for the Panasonic Window A/C (Hong Kong/Macau). + +Exposes the nanoeX feature, which lives inside the full state frame, so toggling +it re-asserts the current mode, temperature, fan and swing. +""" + +from typing import Any + +from homeassistant.components.switch import SwitchEntity +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity + +from . import PanasonicWindowAcHKConfigEntry +from .entity import PanasonicWindowAcHKEntity + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PanasonicWindowAcHKConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the nanoeX switch for one air conditioner.""" + async_add_entities([PanasonicWindowAcHKNanoexSwitch(entry)]) + + +class PanasonicWindowAcHKNanoexSwitch( + PanasonicWindowAcHKEntity, SwitchEntity, RestoreEntity +): + """Toggle nanoeX by re-sending the full state frame.""" + + _attr_translation_key = "nanoex" + _attr_assumed_state = True + + def __init__(self, entry: PanasonicWindowAcHKConfigEntry) -> None: + """Initialize the nanoeX switch.""" + super().__init__(entry, "nanoex") + + async def async_added_to_hass(self) -> None: + """Restore the last assumed nanoeX state across restarts.""" + await super().async_added_to_hass() + last_state = await self.async_get_last_state() + if last_state is not None and last_state.state in (STATE_ON, STATE_OFF): + self._runtime_data.nanoex = last_state.state == STATE_ON + + @property + def is_on(self) -> bool: + """Return whether nanoeX is currently assumed on.""" + return self._runtime_data.nanoex + + async def async_turn_on(self, **kwargs: Any) -> None: + """Enable nanoeX (re-sends the full state frame).""" + self._runtime_data.nanoex = True + await self._async_send_full() + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Disable nanoeX (re-sends the full state frame).""" + self._runtime_data.nanoex = False + await self._async_send_full() + self.async_write_ha_state() diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 7653c9c77cb185..7b24857fc4b62f 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -559,6 +559,7 @@ "paj_gps", "palazzetti", "panasonic_viera", + "panasonic_window_ac_hk", "paperless_ngx", "peblar", "peco", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index c25fac49108eaf..c0db0756fb9c53 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5250,6 +5250,12 @@ "config_flow": true, "iot_class": "local_polling", "name": "Panasonic Viera" + }, + "panasonic_window_ac_hk": { + "integration_type": "device", + "config_flow": true, + "iot_class": "assumed_state", + "name": "Panasonic Window Air Conditioner (Hong Kong/Macau)" } } }, diff --git a/mypy.ini b/mypy.ini index 6871bd7c882417..fbdc5ad5dea024 100644 --- a/mypy.ini +++ b/mypy.ini @@ -4077,6 +4077,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.panasonic_window_ac_hk.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.panel_custom.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/components/panasonic_window_ac_hk/__init__.py b/tests/components/panasonic_window_ac_hk/__init__.py new file mode 100644 index 00000000000000..1265f5da03a1df --- /dev/null +++ b/tests/components/panasonic_window_ac_hk/__init__.py @@ -0,0 +1 @@ +"""Tests for the Panasonic Window A/C (Hong Kong/Macau) integration.""" diff --git a/tests/components/panasonic_window_ac_hk/conftest.py b/tests/components/panasonic_window_ac_hk/conftest.py new file mode 100644 index 00000000000000..4e4db747ca428f --- /dev/null +++ b/tests/components/panasonic_window_ac_hk/conftest.py @@ -0,0 +1,53 @@ +"""Common fixtures for the Panasonic Window A/C tests.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.panasonic_window_ac_hk import PLATFORMS +from homeassistant.components.panasonic_window_ac_hk.const import ( + CONF_INFRARED_EMITTER_ENTITY_ID, + DOMAIN, +) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.infrared import EMITTER_ENTITY_ID as MOCK_INFRARED_ENTITY_ID + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + entry_id="01JTEST0000000000000000000", + title="Panasonic Window AC (Hong Kong)", + data={ + CONF_INFRARED_EMITTER_ENTITY_ID: MOCK_INFRARED_ENTITY_ID, + }, + unique_id=MOCK_INFRARED_ENTITY_ID, + ) + + +@pytest.fixture +def platforms() -> list[Platform]: + """Return the platforms to set up.""" + return PLATFORMS + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_infrared_emitter_entity, + platforms: list[Platform], +) -> MockConfigEntry: + """Set up the Panasonic window A/C integration for testing.""" + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.panasonic_window_ac_hk.PLATFORMS", platforms): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/panasonic_window_ac_hk/test_button.py b/tests/components/panasonic_window_ac_hk/test_button.py new file mode 100644 index 00000000000000..a0a6b70bf1b37b --- /dev/null +++ b/tests/components/panasonic_window_ac_hk/test_button.py @@ -0,0 +1,56 @@ +"""Tests for the Panasonic Window A/C Quiet/Powerful button platform.""" + +import pytest + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.panasonic_window_ac_hk.command import ( + PanasonicWindowAcHKCommand, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant + +from tests.components.common import assert_availability_follows_source_entity +from tests.components.infrared import EMITTER_ENTITY_ID +from tests.components.infrared.common import MockInfraredEmitterEntity + + +@pytest.fixture +def platforms() -> list[Platform]: + """Return platforms to set up.""" + return [Platform.BUTTON] + + +@pytest.mark.parametrize( + ("entity_id", "expected_kind"), + [ + ("button.panasonic_window_ac_hong_kong_quiet", "quiet"), + ("button.panasonic_window_ac_hong_kong_powerful", "powerful"), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_press_sends_short_frame( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + entity_id: str, + expected_kind: str, +) -> None: + """Test pressing a button sends the matching short toggle frame.""" + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert len(mock_infrared_emitter_entity.send_command_calls) == 1 + assert mock_infrared_emitter_entity.send_command_calls[0].get_raw_timings() == ( + PanasonicWindowAcHKCommand.short(expected_kind).get_raw_timings() + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_availability_follows_emitter(hass: HomeAssistant) -> None: + """Test a button follows the infrared emitter availability.""" + await assert_availability_follows_source_entity( + hass, "button.panasonic_window_ac_hong_kong_quiet", EMITTER_ENTITY_ID + ) diff --git a/tests/components/panasonic_window_ac_hk/test_climate.py b/tests/components/panasonic_window_ac_hk/test_climate.py new file mode 100644 index 00000000000000..7aaf54c5b9717b --- /dev/null +++ b/tests/components/panasonic_window_ac_hk/test_climate.py @@ -0,0 +1,258 @@ +"""Tests for the Panasonic Window A/C climate platform.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.climate import ( + ATTR_FAN_MODE, + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + ATTR_SWING_MODE, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_SWING_MODE, + SERVICE_SET_TEMPERATURE, + HVACMode, +) +from homeassistant.components.panasonic_window_ac_hk.command import ( + PanasonicWindowAcHKCommand, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform +from homeassistant.core import HomeAssistant, State + +from tests.common import MockConfigEntry, mock_restore_cache +from tests.components.common import assert_availability_follows_source_entity +from tests.components.infrared import EMITTER_ENTITY_ID +from tests.components.infrared.common import MockInfraredEmitterEntity + +ENTITY_ID = "climate.panasonic_window_ac_hong_kong" + + +@pytest.fixture +def platforms() -> list[Platform]: + """Return platforms to set up.""" + return [Platform.CLIMATE] + + +def _full_timings(**kwargs) -> list[int]: + """Return the raw timings for an expected full state frame.""" + return PanasonicWindowAcHKCommand.full(**kwargs).get_raw_timings() + + +@pytest.mark.usefixtures("init_integration") +async def test_initial_state(hass: HomeAssistant) -> None: + """Test the climate entity starts powered off with the expected options.""" + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == HVACMode.OFF + assert state.attributes[ATTR_HVAC_MODES] == [ + HVACMode.OFF, + HVACMode.AUTO, + HVACMode.COOL, + HVACMode.DRY, + HVACMode.HEAT, + ] + assert state.attributes["fan_modes"] == [ + "auto", + "low", + "mediumLow", + "medium", + "mediumHigh", + "high", + ] + assert state.attributes["swing_modes"] == ["auto", "fixed"] + assert state.attributes["target_temp_step"] == 0.5 + assert state.attributes["min_temp"] == 16 + assert state.attributes["max_temp"] == 30 + + +@pytest.mark.usefixtures("init_integration") +async def test_turn_on_sends_full_frame( + hass: HomeAssistant, mock_infrared_emitter_entity: MockInfraredEmitterEntity +) -> None: + """Test selecting an HVAC mode powers on and sends the full state frame.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.COOL}, + blocking=True, + ) + + assert hass.states.get(ENTITY_ID).state == HVACMode.COOL + assert len(mock_infrared_emitter_entity.send_command_calls) == 1 + command = mock_infrared_emitter_entity.send_command_calls[0] + assert command.modulation == 38000 + assert command.get_raw_timings() == _full_timings( + off=False, + mode="cool", + temp=24.0, + fan="auto", + swing="auto", + nanoex=False, + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_set_attributes_while_on( + hass: HomeAssistant, mock_infrared_emitter_entity: MockInfraredEmitterEntity +) -> None: + """Test temperature/fan/swing changes re-send the full frame while on.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.COOL}, + blocking=True, + ) + mock_infrared_emitter_entity.send_command_calls.clear() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 22.5}, + blocking=True, + ) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: "high"}, + blocking=True, + ) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SWING_MODE: "fixed"}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state.attributes[ATTR_TEMPERATURE] == 22.5 + assert state.attributes[ATTR_FAN_MODE] == "high" + assert state.attributes[ATTR_SWING_MODE] == "fixed" + + timings = [ + c.get_raw_timings() for c in mock_infrared_emitter_entity.send_command_calls + ] + assert timings == [ + _full_timings( + off=False, mode="cool", temp=22.5, fan="auto", swing="auto", nanoex=False + ), + _full_timings( + off=False, mode="cool", temp=22.5, fan="high", swing="auto", nanoex=False + ), + _full_timings( + off=False, mode="cool", temp=22.5, fan="high", swing="fixed", nanoex=False + ), + ] + + +@pytest.mark.usefixtures("init_integration") +async def test_set_attributes_while_off_does_not_send( + hass: HomeAssistant, mock_infrared_emitter_entity: MockInfraredEmitterEntity +) -> None: + """Test changing options while off updates state but sends nothing.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 18.0}, + blocking=True, + ) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: "low"}, + blocking=True, + ) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SWING_MODE: "fixed"}, + blocking=True, + ) + + assert not mock_infrared_emitter_entity.send_command_calls + state = hass.states.get(ENTITY_ID) + assert state.state == HVACMode.OFF + assert state.attributes[ATTR_TEMPERATURE] == 18.0 + assert state.attributes[ATTR_FAN_MODE] == "low" + assert state.attributes[ATTR_SWING_MODE] == "fixed" + + +@pytest.mark.usefixtures("init_integration") +async def test_turn_off_sends_off_frame( + hass: HomeAssistant, mock_infrared_emitter_entity: MockInfraredEmitterEntity +) -> None: + """Test turning off sends an off frame.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.COOL}, + blocking=True, + ) + mock_infrared_emitter_entity.send_command_calls.clear() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + + assert hass.states.get(ENTITY_ID).state == HVACMode.OFF + assert len(mock_infrared_emitter_entity.send_command_calls) == 1 + assert mock_infrared_emitter_entity.send_command_calls[0].get_raw_timings() == ( + _full_timings( + off=True, mode="cool", temp=24.0, fan="auto", swing="auto", nanoex=False + ) + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_availability_follows_emitter(hass: HomeAssistant) -> None: + """Test the climate entity follows the infrared emitter availability.""" + await assert_availability_follows_source_entity(hass, ENTITY_ID, EMITTER_ENTITY_ID) + + +@pytest.mark.parametrize( + "restored_state", + [ + pytest.param(HVACMode.COOL, id="restored_on"), + pytest.param(HVACMode.OFF, id="restored_off"), + ], +) +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_state_restored_on_restart( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + restored_state: HVACMode, +) -> None: + """Test the assumed state is restored from the previous run.""" + mock_restore_cache( + hass, + ( + State( + ENTITY_ID, + restored_state, + { + ATTR_TEMPERATURE: 22.5, + ATTR_FAN_MODE: "high", + ATTR_SWING_MODE: "fixed", + }, + ), + ), + ) + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.panasonic_window_ac_hk.PLATFORMS", + [Platform.CLIMATE], + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.state == restored_state + assert state.attributes[ATTR_TEMPERATURE] == 22.5 + assert state.attributes[ATTR_FAN_MODE] == "high" + assert state.attributes[ATTR_SWING_MODE] == "fixed" diff --git a/tests/components/panasonic_window_ac_hk/test_config_flow.py b/tests/components/panasonic_window_ac_hk/test_config_flow.py new file mode 100644 index 00000000000000..542744375a14eb --- /dev/null +++ b/tests/components/panasonic_window_ac_hk/test_config_flow.py @@ -0,0 +1,72 @@ +"""Tests for the Panasonic Window A/C config flow.""" + +import pytest + +from homeassistant.components.panasonic_window_ac_hk.const import ( + CONF_INFRARED_EMITTER_ENTITY_ID, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry +from tests.components.infrared import EMITTER_ENTITY_ID as MOCK_INFRARED_ENTITY_ID + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_user_flow_success(hass: HomeAssistant) -> None: + """Test the happy-path user config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_INFRARED_EMITTER_ENTITY_ID: MOCK_INFRARED_ENTITY_ID, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Panasonic Window AC (Hong Kong)" + assert result["data"] == { + CONF_INFRARED_EMITTER_ENTITY_ID: MOCK_INFRARED_ENTITY_ID, + } + assert result["result"].unique_id == MOCK_INFRARED_ENTITY_ID + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_user_flow_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test the flow aborts when the emitter is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_INFRARED_EMITTER_ENTITY_ID: MOCK_INFRARED_ENTITY_ID, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("init_infrared") +async def test_user_flow_no_emitters(hass: HomeAssistant) -> None: + """Test the flow aborts when no infrared emitters exist.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_emitters" diff --git a/tests/components/panasonic_window_ac_hk/test_encoder.py b/tests/components/panasonic_window_ac_hk/test_encoder.py new file mode 100644 index 00000000000000..16093a9af7482a --- /dev/null +++ b/tests/components/panasonic_window_ac_hk/test_encoder.py @@ -0,0 +1,11 @@ +"""Tests for the Panasonic Window A/C infrared encoder.""" + +import pytest + +from homeassistant.components.panasonic_window_ac_hk import encoder + + +def test_build_short_frame_rejects_unknown_kind() -> None: + """Test an unknown short-frame kind raises a descriptive ValueError.""" + with pytest.raises(ValueError, match="unknown short-frame kind: 'bogus'"): + encoder.build_short_frame("bogus") diff --git a/tests/components/panasonic_window_ac_hk/test_init.py b/tests/components/panasonic_window_ac_hk/test_init.py new file mode 100644 index 00000000000000..ed6082da12b657 --- /dev/null +++ b/tests/components/panasonic_window_ac_hk/test_init.py @@ -0,0 +1,19 @@ +"""Tests for the Panasonic Window A/C integration setup.""" + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup_and_unload_entry( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Test setting up and unloading a config entry.""" + entry = init_integration + assert entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/panasonic_window_ac_hk/test_switch.py b/tests/components/panasonic_window_ac_hk/test_switch.py new file mode 100644 index 00000000000000..7bd989ed073a18 --- /dev/null +++ b/tests/components/panasonic_window_ac_hk/test_switch.py @@ -0,0 +1,104 @@ +"""Tests for the Panasonic Window A/C nanoeX switch platform.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.panasonic_window_ac_hk.command import ( + PanasonicWindowAcHKCommand, +) +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant, State + +from tests.common import MockConfigEntry, mock_restore_cache +from tests.components.common import assert_availability_follows_source_entity +from tests.components.infrared import EMITTER_ENTITY_ID +from tests.components.infrared.common import MockInfraredEmitterEntity + +ENTITY_ID = "switch.panasonic_window_ac_hong_kong_nanoex" + + +@pytest.fixture +def platforms() -> list[Platform]: + """Return platforms to set up.""" + return [Platform.SWITCH] + + +def _full_timings(**kwargs) -> list[int]: + """Return the raw timings for an expected full state frame.""" + return PanasonicWindowAcHKCommand.full(**kwargs).get_raw_timings() + + +@pytest.mark.usefixtures("init_integration") +async def test_initial_state(hass: HomeAssistant) -> None: + """Test nanoeX starts off.""" + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + + +@pytest.mark.usefixtures("init_integration") +async def test_turn_on_off_sends_full_frame( + hass: HomeAssistant, mock_infrared_emitter_entity: MockInfraredEmitterEntity +) -> None: + """Test toggling nanoeX re-sends the full state frame with the right bit.""" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + assert hass.states.get(ENTITY_ID).state == STATE_ON + assert len(mock_infrared_emitter_entity.send_command_calls) == 1 + assert mock_infrared_emitter_entity.send_command_calls[0].get_raw_timings() == ( + _full_timings( + off=True, mode="cool", temp=24.0, fan="auto", swing="auto", nanoex=True + ) + ) + + mock_infrared_emitter_entity.send_command_calls.clear() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + assert hass.states.get(ENTITY_ID).state == STATE_OFF + assert len(mock_infrared_emitter_entity.send_command_calls) == 1 + assert mock_infrared_emitter_entity.send_command_calls[0].get_raw_timings() == ( + _full_timings( + off=True, mode="cool", temp=24.0, fan="auto", swing="auto", nanoex=False + ) + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_availability_follows_emitter(hass: HomeAssistant) -> None: + """Test the switch follows the infrared emitter availability.""" + await assert_availability_follows_source_entity(hass, ENTITY_ID, EMITTER_ENTITY_ID) + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_nanoex_state_restored_on_restart( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test the assumed nanoeX state is restored from the previous run.""" + mock_restore_cache(hass, (State(ENTITY_ID, STATE_ON),)) + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.panasonic_window_ac_hk.PLATFORMS", + [Platform.SWITCH], + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_ID).state == STATE_ON