Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion homeassistant/brands/panasonic.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
{
"domain": "panasonic",
"name": "Panasonic",
"integrations": ["panasonic_bluray", "panasonic_viera"]
"integrations": [
"panasonic_bluray",
"panasonic_viera",
"panasonic_window_ac_hk"
]
}
62 changes: 62 additions & 0 deletions homeassistant/components/panasonic_window_ac_hk/__init__.py
Original file line number Diff line number Diff line change
@@ -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)
65 changes: 65 additions & 0 deletions homeassistant/components/panasonic_window_ac_hk/button.py
Original file line number Diff line number Diff line change
@@ -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)
145 changes: 145 additions & 0 deletions homeassistant/components/panasonic_window_ac_hk/climate.py
Original file line number Diff line number Diff line change
@@ -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()
47 changes: 47 additions & 0 deletions homeassistant/components/panasonic_window_ac_hk/command.py
Original file line number Diff line number Diff line change
@@ -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)
47 changes: 47 additions & 0 deletions homeassistant/components/panasonic_window_ac_hk/config_flow.py
Original file line number Diff line number Diff line change
@@ -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,
)
),
}
),
)
Loading
Loading