Added AC support for Samsung Infrared (#173692)

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Emanuele
2026-08-30 20:43:11 +02:00
committed by GitHub
co-authored by Copilot Autofix powered by AI
parent 21743843e7
commit 46ae12ca54
9 changed files with 646 additions and 13 deletions
@@ -4,7 +4,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER]
PLATFORMS = [Platform.BUTTON, Platform.CLIMATE, Platform.MEDIA_PLAYER]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@@ -168,7 +168,9 @@ class SamsungIrButton(SamsungIrEntity, InfraredEmitterConsumerEntity, ButtonEnti
description: SamsungIrButtonEntityDescription,
) -> None:
"""Initialize Samsung IR button."""
super().__init__(entry, unique_id_suffix=description.key)
super().__init__(
entry, unique_id_suffix=description.key, device_name="Samsung TV"
)
self._infrared_emitter_entity_id = infrared_emitter_entity_id
self.entity_description = description
@@ -0,0 +1,241 @@
"""Climate platform for Samsung IR integration."""
from dataclasses import dataclass
from typing import Any, override
from infrared_protocols.commands.samsung_ac import (
SamsungAC0292Command,
SamsungAC0292HvacMode,
SamsungACFanMode,
SamsungACSwingMode,
)
from homeassistant.components.climate import (
ATTR_FAN_MODE,
ATTR_HVAC_MODE,
FAN_AUTO,
FAN_HIGH,
FAN_LOW,
FAN_MEDIUM,
ClimateEntity,
ClimateEntityFeature,
HVACMode,
)
from homeassistant.components.infrared import InfraredEmitterConsumerEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_TEMPERATURE,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity
from .const import CONF_DEVICE_TYPE, CONF_INFRARED_EMITTER_ENTITY_ID, SamsungDeviceType
from .entity import SamsungIrEntity
PARALLEL_UPDATES = 1
HA_TO_LIB_HVAC = {
HVACMode.OFF: SamsungAC0292HvacMode.OFF,
HVACMode.COOL: SamsungAC0292HvacMode.COOL,
HVACMode.HEAT: SamsungAC0292HvacMode.HEAT,
HVACMode.DRY: SamsungAC0292HvacMode.DRY,
HVACMode.FAN_ONLY: SamsungAC0292HvacMode.FAN_ONLY,
HVACMode.AUTO: SamsungAC0292HvacMode.AUTO,
}
HA_TO_LIB_FAN = {
FAN_AUTO: SamsungACFanMode.AUTO,
FAN_LOW: SamsungACFanMode.LOW,
FAN_MEDIUM: SamsungACFanMode.MEDIUM,
FAN_HIGH: SamsungACFanMode.HIGH,
}
@dataclass
class _SamsungAcExtraStoredData(ExtraStoredData):
"""Extra data restored alongside the entity's visible state.
Holds the last non-OFF HVAC mode, which isn't part of the visible state (the
entity may currently be OFF) but is needed by turn_on to know which mode to
resume, so it can't be recovered from last_state.state alone when that state
is OFF.
"""
last_on_hvac_mode: str
@override
def as_dict(self) -> dict[str, Any]:
"""Return a dict representation for storage."""
return {"last_on_hvac_mode": self.last_on_hvac_mode}
@classmethod
def from_dict(cls, restored: dict[str, Any]) -> _SamsungAcExtraStoredData | None:
"""Build from a stored dict, or None if it doesn't look valid."""
last_on_hvac_mode = restored.get("last_on_hvac_mode")
if not isinstance(last_on_hvac_mode, str):
return None
return cls(last_on_hvac_mode=last_on_hvac_mode)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Samsung IR climate from a config entry."""
infrared_emitter_entity_id = entry.data[CONF_INFRARED_EMITTER_ENTITY_ID]
device_type = entry.data[CONF_DEVICE_TYPE]
if device_type == SamsungDeviceType.AC:
async_add_entities(
[SamsungIrClimate(entry, infrared_emitter_entity_id, device_type)]
)
class SamsungIrClimate(
SamsungIrEntity, InfraredEmitterConsumerEntity, ClimateEntity, RestoreEntity
):
"""Samsung IR climate entity."""
_attr_name = None
_attr_assumed_state = True
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_fan_modes = [FAN_AUTO, FAN_LOW, FAN_MEDIUM, FAN_HIGH]
_attr_hvac_mode = HVACMode.OFF
_attr_target_temperature = 24.0
_attr_min_temp = 16.0
_attr_max_temp = 30.0
_attr_target_temperature_step = 1.0
_attr_fan_mode = FAN_AUTO
_attr_hvac_modes = [
HVACMode.OFF,
HVACMode.AUTO,
HVACMode.COOL,
HVACMode.HEAT,
HVACMode.DRY,
HVACMode.FAN_ONLY,
]
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
def __init__(
self, entry: ConfigEntry, infrared_emitter_entity_id: str, device_type: str
) -> None:
"""Initialize the climate entity."""
super().__init__(entry, unique_id_suffix="climate", device_name="Samsung AC")
self._infrared_emitter_entity_id = infrared_emitter_entity_id
self._device_type = device_type
self._last_on_hvac_mode = HVACMode.COOL
@override
async def async_added_to_hass(self) -> None:
"""Restore the assumed state, as infrared cannot read it back from the AC."""
await super().async_added_to_hass()
last_state = await self.async_get_last_state()
if last_state is None or last_state.state in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
):
return
if last_state.state in self._attr_hvac_modes:
self._attr_hvac_mode = HVACMode(last_state.state)
if (fan_mode := last_state.attributes.get(ATTR_FAN_MODE)) in HA_TO_LIB_FAN:
self._attr_fan_mode = fan_mode
if (temperature := last_state.attributes.get(ATTR_TEMPERATURE)) is not None:
self._attr_target_temperature = float(temperature)
if self._attr_hvac_mode != HVACMode.OFF:
self._last_on_hvac_mode = self._attr_hvac_mode
elif (last_extra_data := await self.async_get_last_extra_data()) is not None:
restored = _SamsungAcExtraStoredData.from_dict(last_extra_data.as_dict())
if restored is not None and restored.last_on_hvac_mode in (
mode.value for mode in self._attr_hvac_modes if mode != HVACMode.OFF
):
self._last_on_hvac_mode = HVACMode(restored.last_on_hvac_mode)
@property
@override
def extra_restore_state_data(self) -> ExtraStoredData:
"""Return extra data to be restored alongside the entity's state."""
return _SamsungAcExtraStoredData(
last_on_hvac_mode=self._last_on_hvac_mode.value
)
async def _async_send_command(self) -> None:
"""Generate the logical state and delegate transmission to the infrared platform."""
hvac_mode = HA_TO_LIB_HVAC.get(self._attr_hvac_mode, SamsungAC0292HvacMode.OFF)
if hvac_mode is SamsungAC0292HvacMode.OFF:
command = SamsungAC0292Command(hvac_mode=hvac_mode)
else:
fan_mode = HA_TO_LIB_FAN.get(self._attr_fan_mode, SamsungACFanMode.AUTO)
command = SamsungAC0292Command(
hvac_mode=hvac_mode,
target_temperature=int(self._attr_target_temperature),
fan_mode=fan_mode,
swing_mode=SamsungACSwingMode.OFF,
)
await self._send_command(command)
@override
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set HVAC mode."""
self._attr_hvac_mode = hvac_mode
if hvac_mode != HVACMode.OFF:
self._last_on_hvac_mode = hvac_mode
# The unit always transmits a fixed fan value in auto mode, regardless of
# what was previously selected; keep the reported state consistent with
# what's actually being sent.
if hvac_mode == HVACMode.AUTO:
self._attr_fan_mode = FAN_AUTO
await self._async_send_command()
self.async_write_ha_state()
@override
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set fan mode."""
self._attr_fan_mode = fan_mode
await self._async_send_command()
self.async_write_ha_state()
@override
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set temperature."""
if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is not None:
self._attr_hvac_mode = hvac_mode
if hvac_mode != HVACMode.OFF:
self._last_on_hvac_mode = hvac_mode
if hvac_mode == HVACMode.AUTO:
self._attr_fan_mode = FAN_AUTO
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is not None:
self._attr_target_temperature = round(temperature)
if ATTR_HVAC_MODE in kwargs or ATTR_TEMPERATURE in kwargs:
await self._async_send_command()
self.async_write_ha_state()
@override
async def async_turn_on(self) -> None:
"""Turn the entity on."""
await self.async_set_hvac_mode(self._last_on_hvac_mode)
@override
async def async_turn_off(self) -> None:
"""Turn the entity off."""
await self.async_set_hvac_mode(HVACMode.OFF)
@@ -9,7 +9,7 @@ from homeassistant.components.infrared import (
async_get_emitters,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers import entity_registry as er, translation
from homeassistant.helpers.selector import (
EntitySelector,
EntitySelectorConfig,
@@ -25,10 +25,6 @@ from .const import (
SamsungDeviceType,
)
DEVICE_TYPE_NAMES: dict[SamsungDeviceType, str] = {
SamsungDeviceType.TV: "TV",
}
class SamsungIrConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle config flow for Samsung IR."""
@@ -52,14 +48,19 @@ class SamsungIrConfigFlow(ConfigFlow, domain=DOMAIN):
f"samsung_infrared_{device_type}_{entity_id}"
)
self._abort_if_unique_id_configured()
# Get entity name for the title
ent_reg = er.async_get(self.hass)
entry = ent_reg.async_get(entity_id)
entity_name = (
entry.name or entry.original_name or entity_id if entry else entity_id
)
device_type_name = DEVICE_TYPE_NAMES[SamsungDeviceType(device_type)]
device_type_key = SamsungDeviceType(device_type).value
translations = await translation.async_get_translations(
self.hass, self.hass.config.language, "selector", {DOMAIN}
)
device_type_name = translations.get(
f"component.{DOMAIN}.selector.device_type.options.{device_type_key}",
device_type_key,
)
title = f"Samsung {device_type_name} via {entity_name}"
return self.async_create_entry(title=title, data=user_input)
@@ -11,3 +11,4 @@ class SamsungDeviceType(StrEnum):
"""Samsung device types."""
TV = "tv"
AC = "ac"
@@ -12,11 +12,16 @@ class SamsungIrEntity(Entity):
_attr_has_entity_name = True
def __init__(self, entry: ConfigEntry, unique_id_suffix: str) -> None:
def __init__(
self,
entry: ConfigEntry,
unique_id_suffix: str,
device_name: str = "Samsung Device",
) -> None:
"""Initialize Samsung IR entity."""
self._attr_unique_id = f"{entry.entry_id}_{unique_id_suffix}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name="Samsung TV",
name=device_name,
manufacturer="Samsung",
)
@@ -74,7 +74,9 @@ class SamsungIrTvMediaPlayer(
def __init__(self, entry: ConfigEntry, infrared_emitter_entity_id: str) -> None:
"""Initialize Samsung IR media player."""
super().__init__(entry, unique_id_suffix="media_player")
super().__init__(
entry, unique_id_suffix="media_player", device_name="Samsung TV"
)
self._infrared_emitter_entity_id = infrared_emitter_entity_id
@override
@@ -148,6 +148,7 @@
"selector": {
"device_type": {
"options": {
"ac": "Air Conditioner",
"tv": "TV"
}
}
@@ -0,0 +1,380 @@
"""Tests for the Samsung Infrared climate platform."""
from unittest.mock import AsyncMock, patch
from infrared_protocols.commands.samsung_ac import (
SamsungAC0292Command,
SamsungAC0292HvacMode,
SamsungACFanMode,
)
from homeassistant.components.climate import (
ATTR_FAN_MODE,
ATTR_HVAC_MODE,
FAN_AUTO,
FAN_HIGH,
SERVICE_SET_FAN_MODE,
SERVICE_SET_HVAC_MODE,
SERVICE_SET_TEMPERATURE,
HVACMode,
)
from homeassistant.components.samsung_infrared.const import DOMAIN
from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_ON
from homeassistant.core import HomeAssistant, State
from tests.common import (
MockConfigEntry,
mock_restore_cache,
mock_restore_cache_with_extra_data,
)
async def test_samsung_infrared_climate_services(hass: HomeAssistant) -> None:
"""Test climate services send the correct IR commands."""
remote_entity_id = "remote.living_room_ir"
hass.states.async_set(remote_entity_id, STATE_ON)
entry = MockConfigEntry(
domain=DOMAIN,
data={
"infrared_emitter_entity_id": remote_entity_id,
"device_type": "ac",
},
unique_id="samsung_ir_ac_test",
)
entry.add_to_hass(hass)
with patch(
"homeassistant.components.samsung_infrared.climate.SamsungIrClimate._send_command",
new_callable=AsyncMock,
) as mock_send_command:
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
entity_id = "climate.samsung_ac"
state = hass.states.get(entity_id)
assert state is not None
assert state.state != "unavailable"
await hass.services.async_call(
"climate",
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.COOL},
blocking=True,
)
mock_send_command.assert_called_once()
sent_command = mock_send_command.call_args[0][0]
assert isinstance(sent_command, SamsungAC0292Command)
assert sent_command.hvac_mode == SamsungAC0292HvacMode.COOL
mock_send_command.reset_mock()
await hass.services.async_call(
"climate",
SERVICE_SET_TEMPERATURE,
{ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 26},
blocking=True,
)
mock_send_command.assert_called_once()
sent_command = mock_send_command.call_args[0][0]
assert sent_command.target_temperature == 26
mock_send_command.reset_mock()
await hass.services.async_call(
"climate",
SERVICE_SET_FAN_MODE,
{ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_HIGH},
blocking=True,
)
mock_send_command.assert_called_once()
sent_command = mock_send_command.call_args[0][0]
assert sent_command.fan_mode == SamsungACFanMode.HIGH
async def test_samsung_infrared_climate_turn_off_sends_bare_off_command(
hass: HomeAssistant,
) -> None:
"""Test that turning off sends OFF with no temperature, fan, or swing fields.
SamsungAC0292Command raises if hvac_mode is OFF and any of those fields are not
None, so this also guards against a regression that would break every turn_off.
"""
remote_entity_id = "remote.living_room_ir"
hass.states.async_set(remote_entity_id, STATE_ON)
entry = MockConfigEntry(
domain=DOMAIN,
data={
"infrared_emitter_entity_id": remote_entity_id,
"device_type": "ac",
},
unique_id="samsung_ir_ac_test",
)
entry.add_to_hass(hass)
with patch(
"homeassistant.components.samsung_infrared.climate.SamsungIrClimate._send_command",
new_callable=AsyncMock,
) as mock_send_command:
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
entity_id = "climate.samsung_ac"
await hass.services.async_call(
"climate",
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.COOL},
blocking=True,
)
mock_send_command.reset_mock()
await hass.services.async_call(
"climate",
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.OFF},
blocking=True,
)
mock_send_command.assert_called_once()
sent_command = mock_send_command.call_args[0][0]
assert isinstance(sent_command, SamsungAC0292Command)
assert sent_command.hvac_mode == SamsungAC0292HvacMode.OFF
assert sent_command.target_temperature is None
assert sent_command.fan_mode is None
assert sent_command.swing_mode is None
async def test_samsung_infrared_climate_set_temperature_with_hvac_mode(
hass: HomeAssistant,
) -> None:
"""Test that set_temperature applies an included HVAC mode atomically."""
remote_entity_id = "remote.living_room_ir"
hass.states.async_set(remote_entity_id, STATE_ON)
entry = MockConfigEntry(
domain=DOMAIN,
data={
"infrared_emitter_entity_id": remote_entity_id,
"device_type": "ac",
},
unique_id="samsung_ir_ac_test",
)
entry.add_to_hass(hass)
with patch(
"homeassistant.components.samsung_infrared.climate.SamsungIrClimate._send_command",
new_callable=AsyncMock,
) as mock_send_command:
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
entity_id = "climate.samsung_ac"
state = hass.states.get(entity_id)
assert state is not None
assert state.state == HVACMode.OFF
await hass.services.async_call(
"climate",
SERVICE_SET_TEMPERATURE,
{
ATTR_ENTITY_ID: entity_id,
ATTR_HVAC_MODE: HVACMode.COOL,
ATTR_TEMPERATURE: 26,
},
blocking=True,
)
mock_send_command.assert_called_once()
sent_command = mock_send_command.call_args[0][0]
assert sent_command.hvac_mode == SamsungAC0292HvacMode.COOL
assert sent_command.target_temperature == 26
state = hass.states.get(entity_id)
assert state is not None
assert state.state == HVACMode.COOL
assert state.attributes[ATTR_TEMPERATURE] == 26
mock_send_command.reset_mock()
await hass.services.async_call(
"climate",
SERVICE_SET_TEMPERATURE,
{ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 22.5},
blocking=True,
)
mock_send_command.assert_called_once()
sent_command = mock_send_command.call_args[0][0]
assert sent_command.target_temperature == 22
state = hass.states.get(entity_id)
assert state is not None
assert state.attributes[ATTR_TEMPERATURE] == 22
async def test_samsung_infrared_climate_set_hvac_mode_auto_normalizes_fan_mode(
hass: HomeAssistant,
) -> None:
"""Test that switching to AUTO resets the reported fan mode to FAN_AUTO.
SamsungAC0292Command always transmits a fixed fan value in auto mode and
reports fan_mode=None, so the assumed state must not keep showing a previously
selected fan speed (e.g. "high") that isn't actually being sent anymore.
"""
remote_entity_id = "remote.living_room_ir"
hass.states.async_set(remote_entity_id, STATE_ON)
entry = MockConfigEntry(
domain=DOMAIN,
data={
"infrared_emitter_entity_id": remote_entity_id,
"device_type": "ac",
},
unique_id="samsung_ir_ac_test",
)
entry.add_to_hass(hass)
with patch(
"homeassistant.components.samsung_infrared.climate.SamsungIrClimate._send_command",
new_callable=AsyncMock,
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
entity_id = "climate.samsung_ac"
await hass.services.async_call(
"climate",
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.COOL},
blocking=True,
)
await hass.services.async_call(
"climate",
SERVICE_SET_FAN_MODE,
{ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_HIGH},
blocking=True,
)
state = hass.states.get(entity_id)
assert state is not None
assert state.attributes[ATTR_FAN_MODE] == FAN_HIGH
await hass.services.async_call(
"climate",
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.AUTO},
blocking=True,
)
state = hass.states.get(entity_id)
assert state is not None
assert state.attributes[ATTR_FAN_MODE] == FAN_AUTO
async def test_samsung_infrared_climate_restores_state_after_restart(
hass: HomeAssistant,
) -> None:
"""Test that hvac_mode, temperature, and fan_mode survive a restart."""
remote_entity_id = "remote.living_room_ir"
hass.states.async_set(remote_entity_id, STATE_ON)
entry = MockConfigEntry(
domain=DOMAIN,
data={
"infrared_emitter_entity_id": remote_entity_id,
"device_type": "ac",
},
unique_id="samsung_ir_ac_test",
)
entry.add_to_hass(hass)
mock_restore_cache(
hass,
[
State(
"climate.samsung_ac",
HVACMode.HEAT,
{ATTR_TEMPERATURE: 27, ATTR_FAN_MODE: FAN_HIGH},
)
],
)
with patch(
"homeassistant.components.samsung_infrared.climate.SamsungIrClimate._send_command",
new_callable=AsyncMock,
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get("climate.samsung_ac")
assert state is not None
assert state.state == HVACMode.HEAT
assert state.attributes[ATTR_TEMPERATURE] == 27
assert state.attributes[ATTR_FAN_MODE] == FAN_HIGH
async def test_samsung_infrared_climate_turn_on_after_restart_resumes_last_mode(
hass: HomeAssistant,
) -> None:
"""Test that turn_on after a restart resumes the last non-OFF mode, not COOL.
Regression test: without restoring _last_on_hvac_mode, an AC that was last
HEAT and got turned OFF, then restarted, would resume in COOL on turn_on
instead of HEAT.
"""
remote_entity_id = "remote.living_room_ir"
hass.states.async_set(remote_entity_id, STATE_ON)
entry = MockConfigEntry(
domain=DOMAIN,
data={
"infrared_emitter_entity_id": remote_entity_id,
"device_type": "ac",
},
unique_id="samsung_ir_ac_test",
)
entry.add_to_hass(hass)
# The entity's last visible state was OFF, but it had been heating before that.
mock_restore_cache_with_extra_data(
hass,
[
(
State("climate.samsung_ac", HVACMode.OFF, {}),
{"last_on_hvac_mode": HVACMode.HEAT.value},
)
],
)
with patch(
"homeassistant.components.samsung_infrared.climate.SamsungIrClimate._send_command",
new_callable=AsyncMock,
) as mock_send_command:
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
entity_id = "climate.samsung_ac"
state = hass.states.get(entity_id)
assert state is not None
assert state.state == HVACMode.OFF
await hass.services.async_call(
"climate",
"turn_on",
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
state = hass.states.get(entity_id)
assert state is not None
assert state.state == HVACMode.HEAT
sent_command = mock_send_command.call_args[0][0]
assert sent_command.hvac_mode == SamsungAC0292HvacMode.HEAT