Compare commits

...

17 Commits

Author SHA1 Message Date
abmantis 782ffc7675 Merge branch 'dev' of github.com:home-assistant/core into esphome_ir_receiver 2026-05-21 22:24:05 +01:00
abmantis f83adf8f22 Add infrared receiver support to ESPHome 2026-05-21 22:23:25 +01:00
Max Michels 3187289913 Replace duplicate constants with homeassistant.const imports (#171776) 2026-05-22 00:18:54 +03:00
Max Michels 87cecd4a44 Replace duplicate constants with homeassistant.const imports (#171778) 2026-05-22 00:18:23 +03:00
Robert Svensson fed38b0e38 Replace duplicate ATTR_LOCKED constant with homeassistant.const import in deconz (#171779) 2026-05-22 00:17:22 +03:00
Raphael Hehl 6a36d1260b Bump uiprotect to 10.5.0 (#171768)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-05-21 15:42:31 -05:00
Raphael Hehl 49fc1b413d Bump pydantic to 2.13.4 (#171763)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-05-21 14:42:06 -05:00
Abílio Costa bffb0417cc Instruct agents to run prek after doing changes (#171757) 2026-05-21 20:16:26 +01:00
G Johansson 8b8c687fc3 Remove not needed exception handling in dnsip (#171758) 2026-05-21 20:58:32 +02:00
abmantis 25b307924b Merge branch 'dev' of github.com:home-assistant/core into esphome_ir_receiver 2026-05-21 18:09:57 +01:00
abmantis 867b617b60 Merge branch 'dev' of github.com:home-assistant/core into esphome_ir_receiver 2026-05-20 14:57:58 +01:00
abmantis 7e7590c8e2 Address Copilot feedback
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 19:29:28 +01:00
abmantis 49ab12c950 Update broadlink 2026-04-30 19:20:44 +01:00
abmantis 5d65d3e27b Merge branch 'dev' of github.com:home-assistant/core into ir_receiver 2026-04-30 19:18:14 +01:00
abmantis 7eeea9060d Update integrations 2026-04-30 19:07:35 +01:00
abmantis 4086d43a1b Minor improvements; update kitchen_sink 2026-04-30 17:59:27 +01:00
abmantis 62dc48ddd3 Add infrared receiver entity 2026-04-25 00:30:05 +01:00
19 changed files with 222 additions and 74 deletions
+1
View File
@@ -25,6 +25,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- When entering a new environment or worktree, run `script/setup` to set up the virtual environment with all development dependencies (pylint, pre-commit hooks, etc.). This is required before committing.
- .vscode/tasks.json contains useful commands used for development.
- After finishing a code session, run `uv run prek run --all-files` to check for linting and formatting issues.
## Python Syntax Notes
+1
View File
@@ -15,6 +15,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- When entering a new environment or worktree, run `script/setup` to set up the virtual environment with all development dependencies (pylint, pre-commit hooks, etc.). This is required before committing.
- .vscode/tasks.json contains useful commands used for development.
- After finishing a code session, run `uv run prek run --all-files` to check for linting and formatting issues.
## Python Syntax Notes
+2 -2
View File
@@ -26,12 +26,12 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.const import ATTR_LOCKED, ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DeconzConfigEntry
from .const import ATTR_LOCKED, ATTR_OFFSET, ATTR_VALVE
from .const import ATTR_OFFSET, ATTR_VALVE
from .entity import DeconzDevice
from .hub import DeconzHub
-2
View File
@@ -43,8 +43,6 @@ PLATFORMS = [
]
ATTR_DARK = "dark"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_LOCKED = "locked"
ATTR_OFFSET = "offset"
ATTR_ON = "on"
ATTR_VALVE = "valve"
+1 -6
View File
@@ -6,7 +6,6 @@ import logging
import aiodns
from aiodns.error import DNSError
from pycares import AresError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PORT
@@ -78,11 +77,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> boo
) from err
errors = [
result
for result in results
if isinstance(
result, (TimeoutError, DNSError, AresError, asyncio.CancelledError)
)
result for result in results if isinstance(result, (TimeoutError, DNSError))
]
if errors and len(errors) == len(results):
await _close_resolvers()
+16 -5
View File
@@ -53,7 +53,7 @@ def async_static_info_updated(
platform: entity_platform.EntityPlatform,
async_add_entities: AddEntitiesCallback,
info_type: type[_InfoT],
entity_type: type[_EntityT],
entity_type: type[_EntityT] | Callable[[_InfoT], type[_EntityT]],
state_type: type[_StateT],
infos: list[EntityInfo],
) -> None:
@@ -68,6 +68,13 @@ def async_static_info_updated(
ent_reg = er.async_get(hass)
dev_reg = dr.async_get(hass)
def _entity_class(info: EntityInfo) -> type[_EntityT]:
return (
entity_type
if isinstance(entity_type, type)
else entity_type(cast(_InfoT, info))
)
# Track info by (info.device_id, info.key) to properly handle entities
# moving between devices and support sub-devices with overlapping keys
for info in infos:
@@ -88,7 +95,7 @@ def async_static_info_updated(
# Create new entity if it doesn't exist
if not old_info:
entity = entity_type(entry_data, info, state_type)
entity = _entity_class(info)(entry_data, info, state_type)
add_entities.append(entity)
continue
@@ -111,7 +118,7 @@ def async_static_info_updated(
old_info.device_id,
info.device_id,
)
entity = entity_type(entry_data, info, state_type)
entity = _entity_class(info)(entry_data, info, state_type)
add_entities.append(entity)
continue
@@ -163,7 +170,7 @@ def async_static_info_updated(
entry_data.async_signal_entity_removal(info_type, old_info.device_id, info.key)
# Create new entity with the new device_id
add_entities.append(entity_type(entry_data, info, state_type))
add_entities.append(_entity_class(info)(entry_data, info, state_type))
# Anything still in current_infos is now gone
if current_infos:
@@ -188,7 +195,7 @@ async def platform_async_setup_entry(
async_add_entities: AddEntitiesCallback,
*,
info_type: type[_InfoT],
entity_type: type[_EntityT],
entity_type: type[_EntityT] | Callable[[_InfoT], type[_EntityT]],
state_type: type[_StateT],
info_filter: Callable[[_InfoT], bool] | None = None,
) -> None:
@@ -196,6 +203,10 @@ async def platform_async_setup_entry(
This method is in charge of receiving, distributing and storing
info and state updates.
`entity_type` may be either an entity class or a callable that picks the
entity class per static info, allowing a single platform to instantiate
different entity classes based on the info's contents.
"""
entry_data = entry.runtime_data
entry_data.info[info_type] = {}
+76 -10
View File
@@ -1,12 +1,18 @@
"""Infrared platform for ESPHome."""
from functools import partial
import functools
import logging
from aioesphomeapi import EntityState, InfraredCapability, InfraredInfo
from aioesphomeapi.client import InfraredRFReceiveEventModel
from homeassistant.components.infrared import InfraredCommand, InfraredEmitterEntity
from homeassistant.core import callback
from homeassistant.components.infrared import (
InfraredCommand,
InfraredEmitterEntity,
InfraredReceivedSignal,
InfraredReceiverEntity,
)
from homeassistant.core import CALLBACK_TYPE, callback
from .entity import (
EsphomeEntity,
@@ -19,10 +25,8 @@ _LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
class EsphomeInfraredEntity(
EsphomeEntity[InfraredInfo, EntityState], InfraredEmitterEntity
):
"""ESPHome infrared emitter entity using native API."""
class _EsphomeInfraredEntity(EsphomeEntity[InfraredInfo, EntityState]):
"""Common base for ESPHome infrared entities."""
@callback
def _on_device_update(self) -> None:
@@ -32,6 +36,10 @@ class EsphomeInfraredEntity(
# Infrared entities should go available as soon as the device comes online
self.async_write_ha_state()
class EsphomeInfraredEmitterEntity(_EsphomeInfraredEntity, InfraredEmitterEntity):
"""ESPHome infrared emitter entity using native API."""
@convert_api_error_ha_error
async def async_send_command(self, command: InfraredCommand) -> None:
"""Send an IR command."""
@@ -46,10 +54,68 @@ class EsphomeInfraredEntity(
)
async_setup_entry = partial(
class EsphomeInfraredReceiverEntity(_EsphomeInfraredEntity, InfraredReceiverEntity):
"""ESPHome infrared receiver entity using native API."""
_unsub_receive: CALLBACK_TYPE | None = None
async def async_added_to_hass(self) -> None:
"""Register callbacks including IR receive subscription."""
await super().async_added_to_hass()
self._async_subscribe_receive()
async def async_will_remove_from_hass(self) -> None:
"""Unsubscribe from the device on entity removal."""
await super().async_will_remove_from_hass()
if self._unsub_receive is not None:
self._unsub_receive()
self._unsub_receive = None
@callback
def _async_subscribe_receive(self) -> None:
"""Subscribe to IR receive events if the device is connected."""
# Subscribing requires an active API connection; defer to
# _on_device_update when the device is not (yet) available.
if self._unsub_receive is not None or not self._entry_data.available:
return
self._unsub_receive = self._client.subscribe_infrared_rf_receive(
self._on_infrared_rf_receive
)
@callback
def _on_device_update(self) -> None:
"""Call when device updates or entry data changes."""
super()._on_device_update()
if self._entry_data.available:
self._async_subscribe_receive()
elif self._unsub_receive is not None:
self._unsub_receive = None
@callback
def _on_infrared_rf_receive(self, event: InfraredRFReceiveEventModel) -> None:
"""Handle a received IR signal from the device."""
if (
event.key != self._static_info.key
or event.device_id != self._static_info.device_id
):
return
self._handle_received_signal(InfraredReceivedSignal(timings=event.timings))
def _infrared_entity_type_factory(info: InfraredInfo) -> type[_EsphomeInfraredEntity]:
"""Pick the right entity class based on the InfraredInfo capabilities."""
if info.capabilities & InfraredCapability.RECEIVER:
return EsphomeInfraredReceiverEntity
return EsphomeInfraredEmitterEntity
async_setup_entry = functools.partial(
platform_async_setup_entry,
info_type=InfraredInfo,
entity_type=EsphomeInfraredEntity,
entity_type=_infrared_entity_type_factory,
state_type=EntityState,
info_filter=lambda info: bool(info.capabilities & InfraredCapability.TRANSMITTER),
info_filter=lambda info: bool(
info.capabilities
& (InfraredCapability.TRANSMITTER | InfraredCapability.RECEIVER)
),
)
+2 -1
View File
@@ -4,12 +4,13 @@ from dataclasses import dataclass
from typing import cast
from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_entry_oauth2_flow, llm
from .application_credentials import authorization_server_context
from .const import CONF_ACCESS_TOKEN, CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN
from .const import CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN
from .coordinator import ModelContextProtocolCoordinator, TokenManager
from .types import ModelContextProtocolConfigEntry
+2 -8
View File
@@ -13,7 +13,7 @@ from yarl import URL
from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.const import CONF_TOKEN, CONF_URL
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
@@ -24,13 +24,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
from . import async_get_config_entry_implementation
from .application_credentials import authorization_server_context
from .const import (
CONF_ACCESS_TOKEN,
CONF_AUTHORIZATION_URL,
CONF_SCOPE,
CONF_TOKEN_URL,
DOMAIN,
)
from .const import CONF_AUTHORIZATION_URL, CONF_SCOPE, CONF_TOKEN_URL, DOMAIN
from .coordinator import TokenManager, mcp_client
_LOGGER = logging.getLogger(__name__)
-2
View File
@@ -2,8 +2,6 @@
DOMAIN = "mcp"
# pylint: disable-next=home-assistant-duplicate-const
CONF_ACCESS_TOKEN = "access_token"
CONF_AUTHORIZATION_URL = "authorization_url"
CONF_TOKEN_URL = "token_url"
CONF_SCOPE = "scope"
+1 -5
View File
@@ -41,7 +41,7 @@ from mcp.shared.message import SessionMessage
from homeassistant.components import conversation
from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.const import CONF_LLM_HASS_API
from homeassistant.const import CONF_LLM_HASS_API, CONTENT_TYPE_JSON
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.helpers import llm
@@ -56,10 +56,6 @@ _LOGGER = logging.getLogger(__name__)
STREAMABLE_API = "/api/mcp"
TIMEOUT = 60 # Seconds
# Content types
# pylint: disable-next=home-assistant-duplicate-const
CONTENT_TYPE_JSON = "application/json"
# Legacy SSE endpoint
SSE_API = f"/{DOMAIN}/sse"
MESSAGES_API = f"/{DOMAIN}/messages/{{session_id}}"
@@ -9,5 +9,5 @@
"iot_class": "local_push",
"loggers": ["uiprotect"],
"quality_scale": "platinum",
"requirements": ["uiprotect==10.4.1"]
"requirements": ["uiprotect==10.5.0"]
}
+1 -1
View File
@@ -133,7 +133,7 @@ multidict>=6.0.2
Brotli>=1.2.0
# ensure pydantic version does not float since it might have breaking changes
pydantic==2.13.2
pydantic==2.13.4
# Required for Python 3.14.0 compatibility (#119223).
mashumaro>=3.17.0
+1 -1
View File
@@ -3224,7 +3224,7 @@ uasiren==0.0.1
uhooapi==1.2.8
# homeassistant.components.unifiprotect
uiprotect==10.4.1
uiprotect==10.5.0
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.5.7
+1 -1
View File
@@ -18,7 +18,7 @@ license-expression==30.4.3
mock-open==1.4.0
mypy==2.1.0
prek==0.2.28
pydantic==2.13.2
pydantic==2.13.4
pylint==4.0.5
pylint-per-file-ignores==3.2.1
pipdeptree==2.26.1
+1 -1
View File
@@ -117,7 +117,7 @@ multidict>=6.0.2
Brotli>=1.2.0
# ensure pydantic version does not float since it might have breaking changes
pydantic==2.13.2
pydantic==2.13.4
# Required for Python 3.14.0 compatibility (#119223).
mashumaro>=3.17.0
-4
View File
@@ -1,10 +1,8 @@
"""Test for DNS IP integration Init."""
import asyncio
from unittest.mock import patch
from aiodns.error import DNSError
from pycares import AresError
import pytest
from homeassistant.components.dnsip.const import (
@@ -180,8 +178,6 @@ async def test_migrate_error_from_future(hass: HomeAssistant) -> None:
[
TimeoutError(),
DNSError(),
AresError(),
asyncio.CancelledError(),
],
)
async def test_setup_dns_error(hass: HomeAssistant, error: Exception) -> None:
+114 -22
View File
@@ -6,10 +6,17 @@ from aioesphomeapi import (
InfraredCapability,
InfraredInfo,
)
from aioesphomeapi.client import InfraredRFReceiveEventModel
from infrared_protocols.commands.nec import NECCommand
import pytest
from homeassistant.components import infrared
from homeassistant.components.infrared import (
DATA_COMPONENT,
InfraredDeviceClass,
InfraredReceivedSignal,
InfraredReceiverEntity,
)
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -33,32 +40,47 @@ async def _mock_ir_device(
@pytest.mark.parametrize(
("capabilities", "entity_created"),
("capabilities", "expected_device_class", "emitter_count", "receiver_count"),
[
(InfraredCapability.TRANSMITTER, True),
(InfraredCapability.RECEIVER, False),
(InfraredCapability.TRANSMITTER | InfraredCapability.RECEIVER, True),
(InfraredCapability(0), False),
pytest.param(
InfraredCapability.TRANSMITTER,
InfraredDeviceClass.EMITTER,
1,
0,
id="transmitter",
),
pytest.param(
InfraredCapability.RECEIVER,
InfraredDeviceClass.RECEIVER,
0,
1,
id="receiver",
),
],
)
async def test_infrared_entity_transmitter(
async def test_infrared_entity_single_capability(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
capabilities: InfraredCapability,
entity_created: bool,
expected_device_class: InfraredDeviceClass,
emitter_count: int,
receiver_count: int,
) -> None:
"""Test infrared entity with transmitter capability is created."""
"""Test infrared entity is created with the right device class per capability."""
await _mock_ir_device(mock_esphome_device, mock_client, capabilities)
state = hass.states.get(ENTITY_ID)
assert (state is not None) == entity_created
assert (state is not None) == (expected_device_class is not None)
assert state.attributes["device_class"] == expected_device_class
emitters = infrared.async_get_emitters(hass)
assert (len(emitters) == 1) == entity_created
assert len(emitters) == emitter_count
receivers = infrared.async_get_receivers(hass)
assert len(receivers) == receiver_count
async def test_infrared_multiple_entities_mixed_capabilities(
async def test_infrared_entity_dual_capability(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
@@ -77,12 +99,6 @@ async def test_infrared_multiple_entities_mixed_capabilities(
name="IR Receiver",
capabilities=InfraredCapability.RECEIVER,
),
InfraredInfo(
object_id="ir_transceiver",
key=3,
name="IR Transceiver",
capabilities=InfraredCapability.TRANSMITTER | InfraredCapability.RECEIVER,
),
]
await mock_esphome_device(
mock_client=mock_client,
@@ -90,13 +106,18 @@ async def test_infrared_multiple_entities_mixed_capabilities(
states=[],
)
# Only transmitter and transceiver should be created
assert hass.states.get("infrared.test_ir_transmitter") is not None
assert hass.states.get("infrared.test_ir_receiver") is None
assert hass.states.get("infrared.test_ir_transceiver") is not None
transmitter_state = hass.states.get("infrared.test_ir_transmitter")
assert transmitter_state is not None
assert transmitter_state.attributes["device_class"] == InfraredDeviceClass.EMITTER
receiver_state = hass.states.get("infrared.test_ir_receiver")
assert receiver_state is not None
assert receiver_state.attributes["device_class"] == InfraredDeviceClass.RECEIVER
emitters = infrared.async_get_emitters(hass)
assert len(emitters) == 2
assert len(emitters) == 1
receivers = infrared.async_get_receivers(hass)
assert len(receivers) == 1
async def test_infrared_send_command_success(
@@ -146,6 +167,77 @@ async def test_infrared_send_command_failure(
assert exc_info.value.translation_key == "error_communicating_with_device"
async def test_infrared_receiver_signal_dispatched(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test receiver subscribes to events and dispatches received signals."""
await _mock_ir_device(
mock_esphome_device, mock_client, capabilities=InfraredCapability.RECEIVER
)
mock_client.subscribe_infrared_rf_receive.assert_called_once()
on_event = mock_client.subscribe_infrared_rf_receive.call_args[0][0]
receiver = hass.data[DATA_COMPONENT].get_entity(ENTITY_ID)
assert isinstance(receiver, InfraredReceiverEntity)
received_signals: list[InfraredReceivedSignal] = []
receiver.async_subscribe_received_signal(received_signals.append)
timings = [100, -200, 300]
on_event(InfraredRFReceiveEventModel(key=1, device_id=0, timings=timings))
await hass.async_block_till_done()
assert received_signals == [InfraredReceivedSignal(timings=timings)]
assert hass.states.get(ENTITY_ID).state is not STATE_UNAVAILABLE
# Test events with wrong key/device_id are ignored
on_event(InfraredRFReceiveEventModel(key=99, device_id=0, timings=timings))
on_event(InfraredRFReceiveEventModel(key=1, device_id=42, timings=timings))
await hass.async_block_till_done()
assert len(received_signals) == 1
async def test_infrared_receiver_unsubscribes_on_unload(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test receiver unsubscribes from device events when its entry is unloaded."""
mock_device = await _mock_ir_device(
mock_esphome_device, mock_client, capabilities=InfraredCapability.RECEIVER
)
unsub = mock_client.subscribe_infrared_rf_receive.return_value
unsub.assert_not_called()
await hass.config_entries.async_unload(mock_device.entry.entry_id)
await hass.async_block_till_done()
unsub.assert_called_once()
async def test_infrared_receiver_resubscribes_on_reconnect(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test receiver re-subscribes to events after a reconnect."""
mock_device = await _mock_ir_device(
mock_esphome_device, mock_client, capabilities=InfraredCapability.RECEIVER
)
assert mock_client.subscribe_infrared_rf_receive.call_count == 1
await mock_device.mock_disconnect(False)
await hass.async_block_till_done()
await mock_device.mock_connect()
await hass.async_block_till_done()
assert mock_client.subscribe_infrared_rf_receive.call_count == 2
async def test_infrared_entity_availability(
hass: HomeAssistant,
mock_client: APIClient,
+1 -2
View File
@@ -13,13 +13,12 @@ from homeassistant.components.application_credentials import (
async_import_client_credential,
)
from homeassistant.components.mcp.const import (
CONF_ACCESS_TOKEN,
CONF_AUTHORIZATION_URL,
CONF_SCOPE,
CONF_TOKEN_URL,
DOMAIN,
)
from homeassistant.const import CONF_TOKEN, CONF_URL
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component