Compare commits

..

11 Commits

Author SHA1 Message Date
Abílio Costa 07f1ce7fe2 Merge branch 'dev' into ir_receiver 2026-05-05 10:13:43 +01:00
abmantis 2b65c8c992 Fix subscription; update test 2026-05-04 22:46:21 +01:00
abmantis 7a7b0e294c Update kitchen_sink 2026-05-04 22:24:29 +01:00
abmantis a9bcf42388 Lazy init __signal_callbacks 2026-04-30 20:13:02 +01:00
abmantis 309afb3efb RestoreEntity + tests 2026-04-30 19:58:10 +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
319 changed files with 5087 additions and 8990 deletions
+1 -1
View File
@@ -323,7 +323,7 @@ jobs:
exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
publish_container:
name: Publish to ${{ matrix.registry }}
name: Publish meta container for ${{ matrix.registry }}
environment: ${{ needs.init.outputs.channel }}
if: github.repository_owner == 'home-assistant'
needs: ["init", "build_base"]
+2 -2
View File
@@ -281,7 +281,7 @@ jobs:
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
echo "::add-matcher::.github/workflows/matchers/codespell.json"
- name: Run prek
uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3
uses: j178/prek-action@cbc2f23eb5539cf20d82d1aabd0d0ecbcc56f4e3 # v2.0.2
env:
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor
RUFF_OUTPUT_FORMAT: github
@@ -302,7 +302,7 @@ jobs:
with:
persist-credentials: false
- name: Run zizmor
uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3
uses: j178/prek-action@cbc2f23eb5539cf20d82d1aabd0d0ecbcc56f4e3 # v2.0.2
with:
extra-args: --all-files zizmor
-1
View File
@@ -423,7 +423,6 @@ homeassistant.components.otp.*
homeassistant.components.overkiz.*
homeassistant.components.overseerr.*
homeassistant.components.p1_monitor.*
homeassistant.components.paj_gps.*
homeassistant.components.panel_custom.*
homeassistant.components.paperless_ngx.*
homeassistant.components.peblar.*
Generated
+2 -4
View File
@@ -1308,8 +1308,6 @@ CLAUDE.md @home-assistant/core
/tests/components/ovo_energy/ @timmo001
/homeassistant/components/p1_monitor/ @klaasnicolaas
/tests/components/p1_monitor/ @klaasnicolaas
/homeassistant/components/paj_gps/ @skipperro
/tests/components/paj_gps/ @skipperro
/homeassistant/components/palazzetti/ @dotvav
/tests/components/palazzetti/ @dotvav
/homeassistant/components/panel_custom/ @home-assistant/frontend
@@ -1497,8 +1495,8 @@ CLAUDE.md @home-assistant/core
/tests/components/roku/ @ctalkington
/homeassistant/components/romy/ @xeniter
/tests/components/romy/ @xeniter
/homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn
/tests/components/roomba/ @pschmitt @cyr-ius @shenxn
/homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn @Orhideous
/tests/components/roomba/ @pschmitt @cyr-ius @shenxn @Orhideous
/homeassistant/components/roon/ @pavoni
/tests/components/roon/ @pavoni
/homeassistant/components/route_b_smart_meter/ @SeraphicRav
-5
View File
@@ -1,5 +0,0 @@
{
"domain": "sensereo",
"name": "Sensereo",
"iot_standards": ["matter"]
}
-5
View File
@@ -1,5 +0,0 @@
{
"domain": "zunzunbee",
"name": "Zunzunbee",
"iot_standards": ["zigbee"]
}
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["serialx==1.7.1"]
"requirements": ["serialx==1.4.1"]
}
@@ -899,13 +899,12 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
async def async_will_remove_from_hass(self) -> None:
"""Remove listeners when removing automation from Home Assistant."""
await super().async_will_remove_from_hass()
await self._async_disable()
if self.registry_entry and self.registry_entry.entity_id != self.entity_id:
# Entity ID change, do not unload the script or conditions as they will
# be reused.
await self._async_disable()
return
await self._async_disable(stop_actions=False)
await self.action_script.async_unload()
self.action_script.async_unload()
if self._condition is not None:
self._condition.async_unload()
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING
from broadlink.exceptions import BroadlinkException
from broadlink.remote import pulses_to_data as _bl_pulses_to_data
from homeassistant.components.infrared import InfraredCommand, InfraredEntity
from homeassistant.components.infrared import InfraredCommand, InfraredEmitterEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -43,8 +43,8 @@ async def async_setup_entry(
async_add_entities([BroadlinkInfraredEntity(device)])
class BroadlinkInfraredEntity(BroadlinkEntity, InfraredEntity):
"""Broadlink infrared transmitter entity."""
class BroadlinkInfraredEntity(BroadlinkEntity, InfraredEmitterEntity):
"""Broadlink infrared emitter entity."""
_attr_has_entity_name = True
_attr_translation_key = "infrared_emitter"
+23 -3
View File
@@ -1,16 +1,36 @@
"""Provides triggers for buttons."""
from homeassistant.core import HomeAssistant
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import StatelessEntityTriggerBase, Trigger
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
Trigger,
)
from . import DOMAIN
class ButtonPressedTrigger(StatelessEntityTriggerBase):
class ButtonPressedTrigger(EntityTriggerBase):
"""Trigger for button entity presses."""
_domain_specs = {DOMAIN: DomainSpec()}
_schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and different from the current state."""
# UNKNOWN is a valid from_state, otherwise the first time the button is pressed
# would not trigger
if from_state.state == STATE_UNAVAILABLE:
return False
return from_state.state != to_state.state
def is_valid_state(self, state: State) -> bool:
"""Check if the new state is not invalid."""
return state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
TRIGGERS: dict[str, type[Trigger]] = {
+19 -37
View File
@@ -1,12 +1,9 @@
"""Component to embed Google Cast."""
# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
from dataclasses import dataclass, field
from typing import Protocol
from uuid import UUID
from pychromecast import Chromecast
from pychromecast.controllers.multizone import MultizoneManager
from pychromecast.discovery import CastBrowser
from homeassistant.components.media_player import BrowseMedia, MediaType
from homeassistant.config_entries import ConfigEntry
@@ -23,41 +20,12 @@ from .const import DOMAIN
PLATFORMS = [Platform.MEDIA_PLAYER]
type CastConfigEntry = ConfigEntry[CastRuntimeData]
@dataclass
class CastRuntimeData:
"""Runtime data for the Cast integration."""
cast_platforms: dict[str, CastProtocol] = field(default_factory=dict)
unknown_models: dict[str | None, tuple[str | None, str | None]] = field(
default_factory=dict
)
added_cast_devices: set[UUID] = field(default_factory=set)
browser: CastBrowser | None = None
multizone_manager: MultizoneManager | None = None
async def async_setup_entry(hass: HomeAssistant, entry: CastConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Cast from a config entry."""
entry.runtime_data = CastRuntimeData()
hass.data[DOMAIN] = {"cast_platform": {}, "unknown_models": {}}
await home_assistant_cast.async_setup_ha_cast(hass, entry)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@callback
def _register_cast_platform(
hass: HomeAssistant, integration_domain: str, platform: CastProtocol
) -> None:
"""Register a cast platform."""
if (
not hasattr(platform, "async_get_media_browser_root_object")
or not hasattr(platform, "async_browse_media")
or not hasattr(platform, "async_play_media")
):
raise HomeAssistantError(f"Invalid cast platform {platform}")
entry.runtime_data.cast_platforms[integration_domain] = platform
await async_process_integration_platforms(hass, DOMAIN, _register_cast_platform)
return True
@@ -97,13 +65,27 @@ class CastProtocol(Protocol):
"""
async def async_remove_entry(hass: HomeAssistant, entry: CastConfigEntry) -> None:
@callback
def _register_cast_platform(
hass: HomeAssistant, integration_domain: str, platform: CastProtocol
):
"""Register a cast platform."""
if (
not hasattr(platform, "async_get_media_browser_root_object")
or not hasattr(platform, "async_browse_media")
or not hasattr(platform, "async_play_media")
):
raise HomeAssistantError(f"Invalid cast platform {platform}")
hass.data[DOMAIN]["cast_platform"][integration_domain] = platform
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Remove Home Assistant Cast user."""
await home_assistant_cast.async_remove_user(hass, entry)
async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: CastConfigEntry, device_entry: dr.DeviceEntry
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry
) -> bool:
"""Remove cast config entry from a device.
+8 -6
View File
@@ -1,11 +1,16 @@
"""Config flow for Cast."""
from typing import TYPE_CHECKING, Any
from typing import Any
import voluptuous as vol
from homeassistant.components import onboarding
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.const import CONF_UUID
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
@@ -14,9 +19,6 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import CONF_IGNORE_CEC, CONF_KNOWN_HOSTS, DOMAIN
if TYPE_CHECKING:
from . import CastConfigEntry
IGNORE_CEC_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string]))
KNOWN_HOSTS_SCHEMA = vol.Schema(
{
@@ -38,7 +40,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
def async_get_options_flow(
config_entry: CastConfigEntry,
config_entry: ConfigEntry,
) -> CastOptionsFlowHandler:
"""Get the options flow for this handler."""
return CastOptionsFlowHandler()
+7
View File
@@ -12,6 +12,13 @@ DOMAIN = "cast"
# Stores a threading.Lock that is held by the internal pychromecast discovery.
INTERNAL_DISCOVERY_RUNNING_KEY = "cast_discovery_running"
# Stores UUIDs of cast devices that were added as entities. Doesn't store
# None UUIDs.
ADDED_CAST_DEVICES_KEY = "cast_added_cast_devices"
# Stores an audio group manager.
CAST_MULTIZONE_MANAGER_KEY = "cast_multizone_manager"
# Store a CastBrowser
CAST_BROWSER_KEY = "cast_browser"
# Dispatcher signal fired with a ChromecastInfo every time we discover a new
# Chromecast or receive it through configuration
+11 -19
View File
@@ -2,16 +2,17 @@
import logging
import threading
from typing import TYPE_CHECKING
import pychromecast.discovery
import pychromecast.models
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import dispatcher_send
from .const import (
CAST_BROWSER_KEY,
CONF_KNOWN_HOSTS,
INTERNAL_DISCOVERY_RUNNING_KEY,
SIGNAL_CAST_DISCOVERED,
@@ -19,16 +20,11 @@ from .const import (
)
from .helpers import ChromecastInfo, ChromeCastZeroconf
if TYPE_CHECKING:
from . import CastConfigEntry
_LOGGER = logging.getLogger(__name__)
def discover_chromecast(
hass: HomeAssistant,
cast_info: pychromecast.models.CastInfo,
config_entry: CastConfigEntry,
hass: HomeAssistant, cast_info: pychromecast.models.CastInfo
) -> None:
"""Discover a Chromecast."""
@@ -40,7 +36,7 @@ def discover_chromecast(
_LOGGER.error("Discovered chromecast without uuid %s", info)
return
info = info.fill_out_missing_chromecast_info(hass, config_entry)
info = info.fill_out_missing_chromecast_info(hass)
_LOGGER.debug("Discovered new or updated chromecast %s", info)
dispatcher_send(hass, SIGNAL_CAST_DISCOVERED, info)
@@ -53,9 +49,7 @@ def _remove_chromecast(hass: HomeAssistant, info: ChromecastInfo) -> None:
dispatcher_send(hass, SIGNAL_CAST_REMOVED, info)
def setup_internal_discovery(
hass: HomeAssistant, config_entry: CastConfigEntry
) -> None:
def setup_internal_discovery(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Set up the pychromecast internal discovery."""
if INTERNAL_DISCOVERY_RUNNING_KEY not in hass.data:
hass.data[INTERNAL_DISCOVERY_RUNNING_KEY] = threading.Lock()
@@ -69,11 +63,11 @@ def setup_internal_discovery(
def add_cast(self, uuid, _):
"""Handle zeroconf discovery of a new chromecast."""
discover_chromecast(hass, browser.devices[uuid], config_entry)
discover_chromecast(hass, browser.devices[uuid])
def update_cast(self, uuid, _):
"""Handle zeroconf discovery of an updated chromecast."""
discover_chromecast(hass, browser.devices[uuid], config_entry)
discover_chromecast(hass, browser.devices[uuid])
def remove_cast(self, uuid, service, cast_info):
"""Handle zeroconf discovery of a removed chromecast."""
@@ -90,7 +84,7 @@ def setup_internal_discovery(
ChromeCastZeroconf.get_zeroconf(),
config_entry.data.get(CONF_KNOWN_HOSTS),
)
config_entry.runtime_data.browser = browser
hass.data[CAST_BROWSER_KEY] = browser
browser.start_discovery()
def stop_discovery(event):
@@ -104,9 +98,7 @@ def setup_internal_discovery(
config_entry.add_update_listener(config_entry_updated)
async def config_entry_updated(
hass: HomeAssistant, config_entry: CastConfigEntry
) -> None:
async def config_entry_updated(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Handle config entry being updated."""
if browser := config_entry.runtime_data.browser:
browser.host_browser.update_hosts(config_entry.data.get(CONF_KNOWN_HOSTS))
browser = hass.data[CAST_BROWSER_KEY]
browser.host_browser.update_hosts(config_entry.data.get(CONF_KNOWN_HOSTS))
+6 -6
View File
@@ -20,11 +20,11 @@ import pychromecast.socket_client
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
from .const import DOMAIN
if TYPE_CHECKING:
from homeassistant.components import zeroconf
from . import CastConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -56,16 +56,16 @@ class ChromecastInfo:
"""Return the UUID."""
return self.cast_info.uuid
def fill_out_missing_chromecast_info(
self, hass: HomeAssistant, config_entry: CastConfigEntry
) -> ChromecastInfo:
def fill_out_missing_chromecast_info(self, hass: HomeAssistant) -> ChromecastInfo:
"""Return a new ChromecastInfo object with missing attributes filled in.
Uses blocking HTTP / HTTPS.
"""
cast_info = self.cast_info
if self.cast_info.cast_type is None or self.cast_info.manufacturer is None:
unknown_models = config_entry.runtime_data.unknown_models
# Uses legacy hass.data[DOMAIN] pattern
# pylint: disable-next=hass-use-runtime-data
unknown_models = hass.data[DOMAIN]["unknown_models"]
if self.cast_info.model_name not in unknown_models:
# Manufacturer and cast type is not available in mDNS data,
# get it over HTTP
@@ -1,10 +1,8 @@
"""Home Assistant Cast integration for Cast."""
from typing import TYPE_CHECKING
import voluptuous as vol
from homeassistant import auth, core
from homeassistant import auth, config_entries, core
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, dispatcher, instance_id
@@ -13,9 +11,6 @@ from homeassistant.helpers.service import async_register_admin_service
from .const import DOMAIN, SIGNAL_HASS_CAST_SHOW_VIEW, HomeAssistantControllerData
if TYPE_CHECKING:
from . import CastConfigEntry
SERVICE_SHOW_VIEW = "show_lovelace_view"
ATTR_VIEW_PATH = "view_path"
ATTR_URL_PATH = "dashboard_path"
@@ -26,7 +21,9 @@ NO_URL_AVAILABLE_ERROR = (
)
async def async_setup_ha_cast(hass: core.HomeAssistant, entry: CastConfigEntry) -> None:
async def async_setup_ha_cast(
hass: core.HomeAssistant, entry: config_entries.ConfigEntry
):
"""Set up Home Assistant Cast."""
user_id: str | None = entry.data.get("user_id")
user: auth.models.User | None = None
@@ -90,7 +87,9 @@ async def async_setup_ha_cast(hass: core.HomeAssistant, entry: CastConfigEntry)
)
async def async_remove_user(hass: core.HomeAssistant, entry: CastConfigEntry) -> None:
async def async_remove_user(
hass: core.HomeAssistant, entry: config_entries.ConfigEntry
):
"""Remove Home Assistant Cast user."""
user_id: str | None = entry.data.get("user_id")
+25 -34
View File
@@ -1,4 +1,5 @@
"""Provide functionality to interact with Cast devices on the network."""
# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
from collections.abc import Callable
from contextlib import suppress
@@ -41,6 +42,7 @@ from homeassistant.components.media_player import (
MediaType,
async_process_play_media_url,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CAST_APP_ID_HOMEASSISTANT_LOVELACE,
CONF_UUID,
@@ -56,6 +58,8 @@ from homeassistant.util import dt as dt_util
from homeassistant.util.logging import async_create_catching_coro
from .const import (
ADDED_CAST_DEVICES_KEY,
CAST_MULTIZONE_MANAGER_KEY,
CONF_IGNORE_CEC,
DOMAIN,
SIGNAL_CAST_DISCOVERED,
@@ -74,7 +78,7 @@ from .helpers import (
)
if TYPE_CHECKING:
from . import CastConfigEntry, CastProtocol
from . import CastProtocol
_LOGGER = logging.getLogger(__name__)
@@ -106,9 +110,7 @@ def api_error[_CastDeviceT: CastDevice, **_P, _R](
@callback
def _async_create_cast_device(
hass: HomeAssistant, config_entry: CastConfigEntry, info: ChromecastInfo
):
def _async_create_cast_device(hass: HomeAssistant, info: ChromecastInfo):
"""Create a CastDevice entity or dynamic group from the chromecast object.
Returns None if the cast device has already been added.
@@ -119,7 +121,7 @@ def _async_create_cast_device(
return None
# Found a cast with UUID
added_casts = config_entry.runtime_data.added_cast_devices
added_casts = hass.data[ADDED_CAST_DEVICES_KEY]
if info.uuid in added_casts:
# Already added this one, the entity will take care of moved hosts
# itself
@@ -129,19 +131,21 @@ def _async_create_cast_device(
if info.is_dynamic_group:
# This is a dynamic group, do not add it but connect to the service.
group = DynamicCastGroup(hass, config_entry, info)
group = DynamicCastGroup(hass, info)
group.async_setup()
return None
return CastMediaPlayerEntity(hass, config_entry, info)
return CastMediaPlayerEntity(hass, info)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: CastConfigEntry,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Cast from a config entry."""
hass.data.setdefault(ADDED_CAST_DEVICES_KEY, set())
# Import CEC IGNORE attributes
pychromecast.IGNORE_CEC += config_entry.data.get(CONF_IGNORE_CEC) or []
@@ -156,7 +160,7 @@ async def async_setup_entry(
# UUID not matching, ignore.
return
cast_device = _async_create_cast_device(hass, config_entry, discover)
cast_device = _async_create_cast_device(hass, discover)
if cast_device is not None:
async_add_entities([cast_device])
@@ -175,19 +179,13 @@ class CastDevice:
_mz_only: bool
def __init__(
self,
hass: HomeAssistant,
config_entry: CastConfigEntry,
cast_info: ChromecastInfo,
) -> None:
def __init__(self, hass: HomeAssistant, cast_info: ChromecastInfo) -> None:
"""Initialize the cast device."""
self.hass: HomeAssistant = hass
self._config_entry = config_entry
self._cast_info = cast_info
self._chromecast: pychromecast.Chromecast | None = None
self.mz_mgr: MultizoneManager | None = None
self.mz_mgr = None
self._status_listener: CastStatusListener | None = None
self._add_remove_handler: Callable[[], None] | None = None
self._del_remove_handler: Callable[[], None] | None = None
@@ -216,9 +214,7 @@ class CastDevice:
if self._cast_info.uuid is not None:
# Remove the entity from the added casts so that it can dynamically
# be re-added again.
self._config_entry.runtime_data.added_cast_devices.remove(
self._cast_info.uuid
)
self.hass.data[ADDED_CAST_DEVICES_KEY].remove(self._cast_info.uuid)
if self._add_remove_handler:
self._add_remove_handler()
self._add_remove_handler = None
@@ -241,10 +237,10 @@ class CastDevice:
)
self._chromecast = chromecast
runtime_data = self._config_entry.runtime_data
if runtime_data.multizone_manager is None:
runtime_data.multizone_manager = MultizoneManager()
self.mz_mgr = runtime_data.multizone_manager
if CAST_MULTIZONE_MANAGER_KEY not in self.hass.data:
self.hass.data[CAST_MULTIZONE_MANAGER_KEY] = MultizoneManager()
self.mz_mgr = self.hass.data[CAST_MULTIZONE_MANAGER_KEY]
self._status_listener = CastStatusListener(
self, chromecast, self.mz_mgr, self._mz_only
@@ -304,15 +300,10 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
_attr_media_image_remotely_accessible = True
_mz_only = False
def __init__(
self,
hass: HomeAssistant,
config_entry: CastConfigEntry,
cast_info: ChromecastInfo,
) -> None:
def __init__(self, hass: HomeAssistant, cast_info: ChromecastInfo) -> None:
"""Initialize the cast device."""
CastDevice.__init__(self, hass, config_entry, cast_info)
CastDevice.__init__(self, hass, cast_info)
self.cast_status = None
self.media_status = None
@@ -601,7 +592,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
"""Generate root node."""
children = []
# Add media browsers
for platform in self._config_entry.runtime_data.cast_platforms.values():
for platform in self.hass.data[DOMAIN]["cast_platform"].values():
children.extend(
await platform.async_get_media_browser_root_object(
self.hass, self._chromecast.cast_type
@@ -660,7 +651,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
platform: CastProtocol
assert media_content_type is not None
for platform in self._config_entry.runtime_data.cast_platforms.values():
for platform in self.hass.data[DOMAIN]["cast_platform"].values():
browse_media = await platform.async_browse_media(
self.hass,
media_content_type,
@@ -722,7 +713,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
return
# Try the cast platforms
for platform in self._config_entry.runtime_data.cast_platforms.values():
for platform in self.hass.data[DOMAIN]["cast_platform"].values():
result = await platform.async_play_media(
self.hass, self.entity_id, chromecast, media_type, media_id
)
+5 -23
View File
@@ -13,8 +13,8 @@ from homeassistant.helpers.condition import (
Condition,
ConditionConfig,
EntityConditionBase,
EntityNumericalConditionBase,
EntityNumericalConditionWithUnitBase,
make_entity_numerical_condition,
make_entity_state_condition,
)
from homeassistant.util.unit_conversion import TemperatureConverter
@@ -59,33 +59,12 @@ class ClimateTargetTemperatureCondition(EntityNumericalConditionWithUnitBase):
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)}
_unit_converter = TemperatureConverter
def _should_include(self, state: State) -> bool:
"""Skip climate entities that do not expose a target temperature."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_TEMPERATURE) is not None
)
def _get_entity_unit(self, entity_state: State) -> str | None:
"""Get the temperature unit of a climate entity from its state."""
# Climate entities convert temperatures to the system unit via show_temp
return self._hass.config.units.temperature_unit
class ClimateTargetHumidityCondition(EntityNumericalConditionBase):
"""Condition for climate target humidity."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}
_valid_unit = "%"
def _should_include(self, state: State) -> bool:
"""Skip climate entities that do not expose a target humidity."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_HUMIDITY) is not None
)
CONDITIONS: dict[str, type[Condition]] = {
"is_hvac_mode": ClimateHVACModeCondition,
"is_off": make_entity_state_condition(DOMAIN, HVACMode.OFF),
@@ -109,7 +88,10 @@ CONDITIONS: dict[str, type[Condition]] = {
"is_heating": make_entity_state_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING
),
"target_humidity": ClimateTargetHumidityCondition,
"target_humidity": make_entity_numerical_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_temperature": ClimateTargetTemperatureCondition,
}
+10 -38
View File
@@ -8,15 +8,14 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
EntityNumericalStateChangedTriggerBase,
EntityNumericalStateChangedTriggerWithUnitBase,
EntityNumericalStateCrossedThresholdTriggerBase,
EntityNumericalStateCrossedThresholdTriggerWithUnitBase,
EntityNumericalStateTriggerBase,
EntityNumericalStateTriggerWithUnitBase,
EntityTargetStateTriggerBase,
Trigger,
TriggerConfig,
make_entity_numerical_state_changed_trigger,
make_entity_numerical_state_crossed_threshold_trigger,
make_entity_target_state_trigger,
make_entity_transition_trigger,
)
@@ -56,13 +55,6 @@ class _ClimateTargetTemperatureTriggerMixin(EntityNumericalStateTriggerWithUnitB
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)}
_unit_converter = TemperatureConverter
def _should_include(self, state: State) -> bool:
"""Skip climate entities that do not expose a target temperature."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_TEMPERATURE) is not None
)
def _get_entity_unit(self, state: State) -> str | None:
"""Get the temperature unit of a climate entity from its state."""
# Climate entities convert temperatures to the system unit via show_temp
@@ -83,32 +75,6 @@ class ClimateTargetTemperatureCrossedThresholdTrigger(
"""Trigger for climate target temperature value crossing a threshold."""
class _ClimateTargetHumidityTriggerMixin(EntityNumericalStateTriggerBase):
"""Mixin for climate target humidity triggers."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}
_valid_unit = "%"
def _should_include(self, state: State) -> bool:
"""Skip climate entities that do not expose a target humidity."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_HUMIDITY) is not None
)
class ClimateTargetHumidityChangedTrigger(
_ClimateTargetHumidityTriggerMixin, EntityNumericalStateChangedTriggerBase
):
"""Trigger for climate target humidity value changes."""
class ClimateTargetHumidityCrossedThresholdTrigger(
_ClimateTargetHumidityTriggerMixin, EntityNumericalStateCrossedThresholdTriggerBase
):
"""Trigger for climate target humidity value crossing a threshold."""
TRIGGERS: dict[str, type[Trigger]] = {
"hvac_mode_changed": HVACModeChangedTrigger,
"started_cooling": make_entity_target_state_trigger(
@@ -117,8 +83,14 @@ TRIGGERS: dict[str, type[Trigger]] = {
"started_drying": make_entity_target_state_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING
),
"target_humidity_changed": ClimateTargetHumidityChangedTrigger,
"target_humidity_crossed_threshold": ClimateTargetHumidityCrossedThresholdTrigger,
"target_humidity_changed": make_entity_numerical_state_changed_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_humidity_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_temperature_changed": ClimateTargetTemperatureChangedTrigger,
"target_temperature_crossed_threshold": ClimateTargetTemperatureCrossedThresholdTrigger,
"turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF),
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.5.5"]
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.3.24"]
}
+18 -3
View File
@@ -1,6 +1,11 @@
"""Provides triggers for counters."""
from homeassistant.const import CONF_MAXIMUM, CONF_MINIMUM
from homeassistant.const import (
CONF_MAXIMUM,
CONF_MINIMUM,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
@@ -36,7 +41,9 @@ class CounterDecrementedTrigger(CounterBaseIntegerTrigger):
"""Trigger for when a counter is decremented."""
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check that the counter value decreased."""
"""Check if the origin state is valid and the state has changed."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
return int(from_state.state) > int(to_state.state)
@@ -44,7 +51,9 @@ class CounterIncrementedTrigger(CounterBaseIntegerTrigger):
"""Trigger for when a counter is incremented."""
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check that the counter value increased."""
"""Check if the origin state is valid and the state has changed."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
return int(from_state.state) < int(to_state.state)
@@ -53,6 +62,12 @@ class CounterValueBaseTrigger(EntityTriggerBase):
_domain_specs = {DOMAIN: DomainSpec()}
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and the state has changed."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
return from_state.state != to_state.state
class CounterMaxReachedTrigger(CounterValueBaseTrigger):
"""Trigger for when a counter reaches its maximum value."""
+4 -2
View File
@@ -2,7 +2,7 @@
from collections.abc import Mapping
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.trigger import EntityTriggerBase, Trigger
@@ -28,7 +28,9 @@ class CoverTriggerBase(EntityTriggerBase):
return self._get_value(state) == domain_spec.target_value
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check that the relevant cover value changed."""
"""Check if the transition is valid for a cover state change."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
if (from_value := self._get_value(from_state)) is None:
return False
return from_value != self._get_value(to_state)
+23 -4
View File
@@ -6,19 +6,38 @@ from homeassistant.components.event import (
DoorbellEventType,
EventDeviceClass,
)
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import StatelessEntityTriggerBase, Trigger
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
Trigger,
)
class DoorbellRangTrigger(StatelessEntityTriggerBase):
class DoorbellRangTrigger(EntityTriggerBase):
"""Trigger for doorbell event entity when a ring event is received."""
_domain_specs = {EVENT_DOMAIN: DomainSpec(device_class=EventDeviceClass.DOORBELL)}
_schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_state(self, state: State) -> bool:
"""Check if the event type is ring."""
return state.attributes.get(ATTR_EVENT_TYPE) == DoorbellEventType.RING
"""Check if the entity is available and the event type is ring."""
return (
state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
and state.attributes.get(ATTR_EVENT_TYPE) == DoorbellEventType.RING
)
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and different from the current state."""
# UNKNOWN is a valid from_state, otherwise the first time the event is received
# would not trigger
if from_state.state == STATE_UNAVAILABLE:
return False
return from_state.state != to_state.state
TRIGGERS: dict[str, type[Trigger]] = {
@@ -13,9 +13,6 @@ from homeassistant.exceptions import HomeAssistantError
from .const import DOMAIN
from .coordinator import DucoConfigEntry
# MAC addresses and serial numbers are redacted because a Duco installer or
# manufacturer could cross-reference them against an installation registry to
# identify the physical location of the device.
TO_REDACT = {
CONF_HOST,
"mac",
@@ -34,15 +31,9 @@ async def async_get_config_entry_diagnostics(
coordinator = entry.runtime_data
board = asdict(coordinator.board_info)
# `time` is a Unix epoch timestamp of the last board info fetch; not useful for support triage.
board.pop("time")
if board["public_api_version"] is None:
board.pop("public_api_version")
if board["software_version"] is None:
board.pop("software_version")
try:
api_info_obj = await coordinator.client.async_get_api_info()
lan_info = await coordinator.client.async_get_lan_info()
duco_diags = await coordinator.client.async_get_diagnostics()
write_remaining = await coordinator.client.async_get_write_req_remaining()
@@ -52,15 +43,10 @@ async def async_get_config_entry_diagnostics(
translation_key="connection_error",
) from err
api_info: dict[str, Any] = {"public_api_version": api_info_obj.public_api_version}
if api_info_obj.reported_api_version is not None:
api_info["reported_api_version"] = api_info_obj.reported_api_version
return async_redact_data(
{
"entry_data": entry.data,
"board_info": board,
"api_info": api_info,
"lan_info": asdict(lan_info),
"nodes": {
str(node_id): asdict(node)
+1 -1
View File
@@ -13,7 +13,7 @@
"iot_class": "local_polling",
"loggers": ["duco"],
"quality_scale": "platinum",
"requirements": ["python-duco-client==0.4.1"],
"requirements": ["python-duco-client==0.4.0"],
"zeroconf": [
{
"name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*",
@@ -137,6 +137,10 @@ class EcovacsConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
if not self.show_advanced_options:
return await self.async_step_auth()
if user_input:
self._mode = user_input[CONF_MODE]
return await self.async_step_auth()
@@ -72,7 +72,6 @@ PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.LIGHT,
Platform.NUMBER,
Platform.SCENE,
Platform.SENSOR,
Platform.SWITCH,
+1 -1
View File
@@ -16,5 +16,5 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["elkm1_lib"],
"requirements": ["elkm1-lib==2.2.15"]
"requirements": ["elkm1-lib==2.2.13"]
}
-77
View File
@@ -1,77 +0,0 @@
"""Support for ElkM1 number entities."""
import logging
from typing import Any, cast
from elkm1_lib.const import SettingFormat
from elkm1_lib.elements import Element
from elkm1_lib.settings import Setting
from homeassistant.components.number import NumberDeviceClass, NumberEntity
from homeassistant.const import UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ElkM1ConfigEntry
from .entity import ElkAttachedEntity, ElkEntity, create_elk_entities
from .models import ELKM1Data
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ElkM1ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Elk-M1 number platform."""
elk_data = config_entry.runtime_data
elk = elk_data.elk
entities: list[ElkEntity] = []
number_settings = [
setting
for setting in cast(list[Setting], elk.settings)
if setting.value_format in (SettingFormat.NUMBER, SettingFormat.TIMER)
]
create_elk_entities(
elk_data,
number_settings,
"setting",
ElkNumberSetting,
entities,
)
async_add_entities(entities)
class ElkNumberSetting(ElkAttachedEntity, NumberEntity):
"""Representation of an Elk-M1 Number Setting."""
_element: Setting
_attr_native_min_value = 0
_attr_native_max_value = 65535
_attr_native_step = 1
def __init__(self, element: Setting, elk: Any, elk_data: ELKM1Data) -> None:
"""Initialize the number setting."""
super().__init__(element, elk, elk_data)
if element.value_format == SettingFormat.TIMER:
self._attr_device_class = NumberDeviceClass.DURATION
self._attr_native_unit_of_measurement = UnitOfTime.SECONDS
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
# Guard against the panel possibly changing the underlying
# type without us knowing about the change
if isinstance(self._element.value, int):
self._attr_native_value = self._element.value
else:
self._attr_available = False
_LOGGER.warning(
"Setting type for '%s' differs between the ElkM1 and the entity. Restart the integration to fix",
self.entity_id,
)
async def async_set_native_value(self, value: float) -> None:
"""Set the value of the setting."""
self._element.set(int(value))
+1 -3
View File
@@ -199,9 +199,7 @@ class ElkSetting(ElkSensor):
_element: Setting
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
self._attr_native_value = (
None if self._element.value is None else str(self._element.value)
)
self._attr_native_value = self._element.value
@property
def extra_state_attributes(self) -> dict[str, Any]:
+5 -3
View File
@@ -5,7 +5,7 @@ import logging
from aioesphomeapi import EntityState, InfraredCapability, InfraredInfo
from homeassistant.components.infrared import InfraredCommand, InfraredEntity
from homeassistant.components.infrared import InfraredCommand, InfraredEmitterEntity
from homeassistant.core import callback
from .entity import (
@@ -19,8 +19,10 @@ _LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
class EsphomeInfraredEntity(EsphomeEntity[InfraredInfo, EntityState], InfraredEntity):
"""ESPHome infrared entity using native API."""
class EsphomeInfraredEntity(
EsphomeEntity[InfraredInfo, EntityState], InfraredEmitterEntity
):
"""ESPHome infrared emitter entity using native API."""
@callback
def _on_device_update(self) -> None:
+18 -5
View File
@@ -2,13 +2,13 @@
import voluptuous as vol
from homeassistant.const import CONF_OPTIONS
from homeassistant.const import CONF_OPTIONS, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
StatelessEntityTriggerBase,
EntityTriggerBase,
Trigger,
TriggerConfig,
)
@@ -28,7 +28,7 @@ EVENT_RECEIVED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend(
)
class EventReceivedTrigger(StatelessEntityTriggerBase):
class EventReceivedTrigger(EntityTriggerBase):
"""Trigger for event entity when it receives a matching event."""
_domain_specs = {DOMAIN: DomainSpec()}
@@ -39,9 +39,22 @@ class EventReceivedTrigger(StatelessEntityTriggerBase):
super().__init__(hass, config)
self._event_types = set(self._options[CONF_EVENT_TYPE])
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and different from the current state."""
# UNKNOWN is a valid from_state, otherwise the first time the event is received
# would not trigger
if from_state.state == STATE_UNAVAILABLE:
return False
return from_state.state != to_state.state
def is_valid_state(self, state: State) -> bool:
"""Check if the event type matches one of the configured types."""
return state.attributes.get(ATTR_EVENT_TYPE) in self._event_types
"""Check if the event type is valid and matches one of the configured types."""
return (
state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
and state.attributes.get(ATTR_EVENT_TYPE) in self._event_types
)
TRIGGERS: dict[str, type[Trigger]] = {
@@ -1,5 +1,7 @@
"""DataUpdateCoordinator for Fluss+ integration."""
from __future__ import annotations
import asyncio
from typing import Any
@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260429.3"]
"requirements": ["home-assistant-frontend==20260429.2"]
}
@@ -15,5 +15,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["bleak", "bleak_esphome", "gardena_bluetooth"],
"requirements": ["gardena-bluetooth==2.8.1"]
"requirements": ["gardena-bluetooth==2.4.0"]
}
@@ -596,9 +596,7 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if not self.data:
await self.async_refresh()
return self.api.sph_read_ac_charge_times(
self.device_id, settings_data=self.data
)
return self.api.sph_read_ac_charge_times(settings_data=self.data)
async def read_ac_discharge_times(self) -> dict:
"""Read AC discharge time settings from SPH device cache."""
@@ -611,6 +609,4 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if not self.data:
await self.async_refresh()
return self.api.sph_read_ac_discharge_times(
self.device_id, settings_data=self.data
)
return self.api.sph_read_ac_discharge_times(settings_data=self.data)
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["growattServer"],
"quality_scale": "silver",
"requirements": ["growattServer==2.1.0"]
"requirements": ["growattServer==1.9.0"]
}
+8 -13
View File
@@ -12,7 +12,6 @@ import voluptuous as vol
from homeassistant.auth.models import User
from homeassistant.auth.providers import homeassistant as auth_ha
from homeassistant.components.http import KEY_HASS, KEY_HASS_USER, HomeAssistantView
from homeassistant.components.http.const import is_supervisor_unix_socket_request
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
@@ -42,18 +41,14 @@ class HassIOBaseAuth(HomeAssistantView):
def _check_access(self, request: web.Request) -> None:
"""Check if this call is from Supervisor."""
# Requests over the Supervisor Unix socket are authenticated by the
# http auth middleware as the Supervisor user, so the caller-IP check
# below does not apply (and would crash, since `peername` is empty for
# Unix sockets). The user-ID check still runs to ensure only the
# Supervisor user can reach this endpoint.
if not is_supervisor_unix_socket_request(request):
hassio_ip = os.environ["SUPERVISOR"].split(":")[0]
assert request.transport
peername = request.transport.get_extra_info("peername")
if not peername or ip_address(peername[0]) != ip_address(hassio_ip):
_LOGGER.error("Invalid auth request from %s", request.remote)
raise HTTPUnauthorized
# Check caller IP
hassio_ip = os.environ["SUPERVISOR"].split(":")[0]
assert request.transport
if ip_address(request.transport.get_extra_info("peername")[0]) != ip_address(
hassio_ip
):
_LOGGER.error("Invalid auth request from %s", request.remote)
raise HTTPUnauthorized
# Check caller token
if request[KEY_HASS_USER].id != self.user.id:
+5 -11
View File
@@ -44,20 +44,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: HiveConfigEntry) -> bool
except HiveReauthRequired as err:
raise ConfigEntryAuthFailed from err
hub_data = devices["parent"][0]
connections: set[tuple[str, str]] = set()
if mac := hub_data.get("macAddress"):
connections.add((dr.CONNECTION_NETWORK_MAC, dr.format_mac(mac)))
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, hub_data["device_id"])},
connections=connections,
name=hub_data["hiveName"],
model=hub_data["deviceData"]["model"],
sw_version=hub_data["deviceData"]["version"],
manufacturer=hub_data["deviceData"]["manufacturer"],
identifiers={(DOMAIN, devices["parent"][0]["device_id"])},
name=devices["parent"][0]["hiveName"],
model=devices["parent"][0]["deviceData"]["model"],
sw_version=devices["parent"][0]["deviceData"]["version"],
manufacturer=devices["parent"][0]["deviceData"]["manufacturer"],
)
await hass.config_entries.async_forward_entry_setups(
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
"requirements": ["holidays==0.96", "babel==2.15.0"]
"requirements": ["holidays==0.95", "babel==2.15.0"]
}
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["homematicip"],
"requirements": ["homematicip==2.11.0"]
"requirements": ["homematicip==2.9.0"]
}
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING
import voluptuous as vol
from homeassistant.const import ATTR_MODE, CONF_OPTIONS, PERCENTAGE, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, State
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
@@ -13,8 +13,8 @@ from homeassistant.helpers.condition import (
ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL,
Condition,
ConditionConfig,
EntityNumericalConditionBase,
EntityStateConditionBase,
make_entity_numerical_condition,
make_entity_state_condition,
)
from homeassistant.helpers.entity import get_supported_features
@@ -46,20 +46,6 @@ def _supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> boo
return False
class IsTargetHumidityCondition(EntityNumericalConditionBase):
"""Condition for humidifier target humidity."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}
_valid_unit = PERCENTAGE
def _should_include(self, state: State) -> bool:
"""Skip humidifier entities that do not expose a target humidity."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_HUMIDITY) is not None
)
class IsModeCondition(EntityStateConditionBase):
"""Condition for humidifier mode."""
@@ -93,7 +79,10 @@ CONDITIONS: dict[str, type[Condition]] = {
{DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.HUMIDIFYING
),
"is_mode": IsModeCondition,
"is_target_humidity": IsTargetHumidityCondition,
"is_target_humidity": make_entity_numerical_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit=PERCENTAGE,
),
}
+3 -26
View File
@@ -14,9 +14,9 @@ from homeassistant.components.weather import (
DOMAIN as WEATHER_DOMAIN,
)
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant, State
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import Condition, EntityNumericalConditionBase
from homeassistant.helpers.condition import Condition, make_entity_numerical_condition
HUMIDITY_DOMAIN_SPECS = {
CLIMATE_DOMAIN: DomainSpec(
@@ -31,31 +31,8 @@ HUMIDITY_DOMAIN_SPECS = {
),
}
class HumidityCondition(EntityNumericalConditionBase):
"""Condition for humidity value across multiple domains."""
_domain_specs = HUMIDITY_DOMAIN_SPECS
_valid_unit = PERCENTAGE
def _should_include(self, state: State) -> bool:
"""Skip attribute-source entities that lack the humidity attribute.
Mirrors the humidity trigger: for climate / humidifier / weather
(attribute-based), the entity is filtered when the source attribute
is absent; sensor entities (state-value-based) fall through to the
base impl.
"""
if not super()._should_include(state):
return False
domain_spec = self._domain_specs[state.domain]
if domain_spec.value_source is None:
return True
return state.attributes.get(domain_spec.value_source) is not None
CONDITIONS: dict[str, type[Condition]] = {
"is_value": HumidityCondition,
"is_value": make_entity_numerical_condition(HUMIDITY_DOMAIN_SPECS, PERCENTAGE),
}
+9 -43
View File
@@ -13,13 +13,12 @@ from homeassistant.components.weather import (
ATTR_WEATHER_HUMIDITY,
DOMAIN as WEATHER_DOMAIN,
)
from homeassistant.core import HomeAssistant, State
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
EntityNumericalStateChangedTriggerBase,
EntityNumericalStateCrossedThresholdTriggerBase,
EntityNumericalStateTriggerBase,
Trigger,
make_entity_numerical_state_changed_trigger,
make_entity_numerical_state_crossed_threshold_trigger,
)
HUMIDITY_DOMAIN_SPECS: dict[str, DomainSpec] = {
@@ -37,46 +36,13 @@ HUMIDITY_DOMAIN_SPECS: dict[str, DomainSpec] = {
),
}
class _HumidityTriggerMixin(EntityNumericalStateTriggerBase):
"""Mixin for humidity triggers providing entity filtering."""
_domain_specs = HUMIDITY_DOMAIN_SPECS
_valid_unit = "%"
def _should_include(self, state: State) -> bool:
"""Skip attribute-source entities that lack the humidity attribute.
For domains whose tracked value comes from an attribute
(climate / humidifier / weather), require the attribute to be
present; otherwise the all/count check would treat an entity that
cannot report a humidity as a non-match and block behavior=last.
Sensor entities source their value from `state.state`, so they
fall through to the base impl.
"""
if not super()._should_include(state):
return False
domain_spec = self._domain_specs[state.domain]
if domain_spec.value_source is None:
return True
return state.attributes.get(domain_spec.value_source) is not None
class HumidityChangedTrigger(
_HumidityTriggerMixin, EntityNumericalStateChangedTriggerBase
):
"""Trigger for humidity value changes across multiple domains."""
class HumidityCrossedThresholdTrigger(
_HumidityTriggerMixin, EntityNumericalStateCrossedThresholdTriggerBase
):
"""Trigger for humidity value crossing a threshold across multiple domains."""
TRIGGERS: dict[str, type[Trigger]] = {
"changed": HumidityChangedTrigger,
"crossed_threshold": HumidityCrossedThresholdTrigger,
"changed": make_entity_numerical_state_changed_trigger(
HUMIDITY_DOMAIN_SPECS, valid_unit="%"
),
"crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
HUMIDITY_DOMAIN_SPECS, valid_unit="%"
),
}
@@ -13,5 +13,5 @@
"documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.8.1"]
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.4.0"]
}
+19 -10
View File
@@ -76,12 +76,14 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional(CONF_SEARCH, default="UnSeen UnDeleted"): str,
# The default for new entries is to not include text and headers
vol.Optional(CONF_EVENT_MESSAGE_DATA, default=[]): EVENT_MESSAGE_DATA_SELECTOR,
vol.Optional(
CONF_SSL_CIPHER_LIST, default=SSLCipherList.PYTHON_DEFAULT
): CIPHER_SELECTOR,
vol.Optional(CONF_VERIFY_SSL, default=True): BOOLEAN_SELECTOR,
}
)
CONFIG_SCHEMA_ADVANCED = {
vol.Optional(
CONF_SSL_CIPHER_LIST, default=SSLCipherList.PYTHON_DEFAULT
): CIPHER_SELECTOR,
vol.Optional(CONF_VERIFY_SSL, default=True): BOOLEAN_SELECTOR,
}
OPTIONS_SCHEMA = vol.Schema(
{
@@ -91,15 +93,18 @@ OPTIONS_SCHEMA = vol.Schema(
vol.Optional(
CONF_EVENT_MESSAGE_DATA, default=MESSAGE_DATA_OPTIONS
): EVENT_MESSAGE_DATA_SELECTOR,
vol.Optional(CONF_CUSTOM_EVENT_DATA_TEMPLATE): TEMPLATE_SELECTOR,
vol.Optional(CONF_MAX_MESSAGE_SIZE, default=DEFAULT_MAX_MESSAGE_SIZE): vol.All(
cv.positive_int,
vol.Range(min=DEFAULT_MAX_MESSAGE_SIZE, max=MAX_MESSAGE_SIZE_LIMIT),
),
vol.Optional(CONF_ENABLE_PUSH, default=True): BOOLEAN_SELECTOR,
}
)
OPTIONS_SCHEMA_ADVANCED = {
vol.Optional(CONF_CUSTOM_EVENT_DATA_TEMPLATE): TEMPLATE_SELECTOR,
vol.Optional(CONF_MAX_MESSAGE_SIZE, default=DEFAULT_MAX_MESSAGE_SIZE): vol.All(
cv.positive_int,
vol.Range(min=DEFAULT_MAX_MESSAGE_SIZE, max=MAX_MESSAGE_SIZE_LIMIT),
),
vol.Optional(CONF_ENABLE_PUSH, default=True): BOOLEAN_SELECTOR,
}
async def validate_input(
hass: HomeAssistant, user_input: dict[str, Any]
@@ -146,6 +151,8 @@ class IMAPConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle the initial step."""
schema = CONFIG_SCHEMA
if self.show_advanced_options:
schema = schema.extend(CONFIG_SCHEMA_ADVANCED)
if user_input is None:
return self.async_show_form(step_id="user", data_schema=schema)
@@ -243,6 +250,8 @@ class ImapOptionsFlow(OptionsFlow):
return self.async_create_entry(data={})
schema = OPTIONS_SCHEMA
if self.show_advanced_options:
schema = schema.extend(OPTIONS_SCHEMA_ADVANCED)
schema = self.add_suggested_values_to_schema(schema, entry_data)
return self.async_show_form(step_id="init", data_schema=schema, errors=errors)
+169 -14
View File
@@ -1,15 +1,20 @@
"""Provides functionality to interact with infrared devices."""
from abc import abstractmethod
from collections.abc import Callable
from dataclasses import dataclass
from datetime import timedelta
from enum import StrEnum
import logging
from typing import final
from infrared_protocols import Command as InfraredCommand
from propcache.api import cached_property
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity import EntityDescription
@@ -23,15 +28,30 @@ from .const import DOMAIN
__all__ = [
"DOMAIN",
"InfraredEntity",
"InfraredEntityDescription",
"InfraredEmitterEntity",
"InfraredEmitterEntityDescription",
"InfraredReceivedSignal",
"InfraredReceiverEntity",
"InfraredReceiverEntityDescription",
"async_get_emitters",
"async_get_receivers",
"async_send_command",
"async_subscribe_receiver",
]
class InfraredDeviceClass(StrEnum):
"""Device class for infrared entities."""
RECEIVER = "receiver"
EMITTER = "emitter"
_LOGGER = logging.getLogger(__name__)
DATA_COMPONENT: HassKey[EntityComponent[InfraredEntity]] = HassKey(DOMAIN)
DATA_COMPONENT: HassKey[
EntityComponent[InfraredEmitterEntity | InfraredReceiverEntity]
] = HassKey(DOMAIN)
ENTITY_ID_FORMAT = DOMAIN + ".{}"
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
@@ -40,9 +60,9 @@ SCAN_INTERVAL = timedelta(seconds=30)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the infrared domain."""
component = hass.data[DATA_COMPONENT] = EntityComponent[InfraredEntity](
_LOGGER, DOMAIN, hass, SCAN_INTERVAL
)
component = hass.data[DATA_COMPONENT] = EntityComponent[
InfraredEmitterEntity | InfraredReceiverEntity
](_LOGGER, DOMAIN, hass, SCAN_INTERVAL)
await component.async_setup(config)
return True
@@ -65,7 +85,25 @@ def async_get_emitters(hass: HomeAssistant) -> list[str]:
if component is None:
return []
return [entity.entity_id for entity in component.entities]
return [
entity.entity_id
for entity in component.entities
if isinstance(entity, InfraredEmitterEntity)
]
@callback
def async_get_receivers(hass: HomeAssistant) -> list[str]:
"""Get all infrared receiver entity IDs."""
component = hass.data.get(DATA_COMPONENT)
if component is None:
return []
return [
entity.entity_id
for entity in component.entities
if isinstance(entity, InfraredReceiverEntity)
]
async def async_send_command(
@@ -89,7 +127,7 @@ async def async_send_command(
ent_reg = er.async_get(hass)
entity_id = er.async_validate_entity_id(ent_reg, entity_id_or_uuid)
entity = component.get_entity(entity_id)
if entity is None:
if entity is None or not isinstance(entity, InfraredEmitterEntity):
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="entity_not_found",
@@ -102,14 +140,62 @@ async def async_send_command(
await entity.async_send_command_internal(command)
class InfraredEntityDescription(EntityDescription, frozen_or_thawed=True):
"""Describes infrared entities."""
@callback
def async_subscribe_receiver(
hass: HomeAssistant,
entity_id_or_uuid: str,
signal_callback: Callable[[InfraredReceivedSignal], None],
) -> CALLBACK_TYPE:
"""Subscribe to IR signals from a specific receiver entity.
Raises:
HomeAssistantError: If the receiver entity is not found.
"""
component = hass.data.get(DATA_COMPONENT)
if component is None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="component_not_loaded",
)
ent_reg = er.async_get(hass)
try:
entity_id = er.async_validate_entity_id(ent_reg, entity_id_or_uuid)
except vol.Invalid as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="receiver_not_found",
translation_placeholders={"entity_id": entity_id_or_uuid},
) from err
entity = component.get_entity(entity_id)
if entity is None or not isinstance(entity, InfraredReceiverEntity):
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="receiver_not_found",
translation_placeholders={"entity_id": entity_id},
)
return entity.async_subscribe_received_signal(signal_callback)
class InfraredEntity(RestoreEntity):
"""Base class for infrared transmitter entities."""
@dataclass(frozen=True, slots=True)
class InfraredReceivedSignal:
"""Represents a received IR signal."""
entity_description: InfraredEntityDescription
timings: list[int]
modulation: int | None = None
class InfraredEmitterEntityDescription(EntityDescription, frozen_or_thawed=True):
"""Describes infrared emitter entities."""
class InfraredEmitterEntity(RestoreEntity):
"""Base class for infrared emitter entities."""
entity_description: InfraredEmitterEntityDescription
_attr_device_class: InfraredDeviceClass = InfraredDeviceClass.EMITTER
_attr_should_poll = False
_attr_state: None = None
@@ -149,3 +235,72 @@ class InfraredEntity(RestoreEntity):
Raises:
HomeAssistantError: If transmission fails.
"""
class InfraredReceiverEntityDescription(EntityDescription, frozen_or_thawed=True):
"""Describes infrared receiver entities."""
class InfraredReceiverEntity(RestoreEntity):
"""Base class for infrared receiver entities."""
entity_description: InfraredReceiverEntityDescription
_attr_device_class: InfraredDeviceClass = InfraredDeviceClass.RECEIVER
_attr_should_poll = False
_attr_state: None = None
__last_signal_received: str | None = None
@cached_property
def __signal_callbacks(self) -> set[Callable[[InfraredReceivedSignal], None]]:
"""Subscriber callback set, lazily initialized on first access."""
return set()
@property
@final
def state(self) -> str | None:
"""Return the entity state."""
return self.__last_signal_received
@final
async def async_internal_added_to_hass(self) -> None:
"""Call when the infrared entity is added to hass."""
await super().async_internal_added_to_hass()
state = await self.async_get_last_state()
if state is not None and state.state not in (STATE_UNAVAILABLE, None):
self.__last_signal_received = state.state
@final
def _handle_received_signal(self, signal: InfraredReceivedSignal) -> None:
"""Handle a received IR signal.
Should not be overridden. To be called by platform implementations when a
signal is received.
"""
self.__last_signal_received = dt_util.utcnow().isoformat(
timespec="milliseconds"
)
self.async_write_ha_state()
for signal_callback in tuple(self.__signal_callbacks):
try:
signal_callback(signal)
except Exception:
_LOGGER.exception("Error in signal callback for %s", self.entity_id)
@callback
def async_subscribe_received_signal(
self,
signal_callback: Callable[[InfraredReceivedSignal], None],
) -> CALLBACK_TYPE:
"""Subscribe to received IR signals.
Returns a callable to unsubscribe.
"""
callbacks = self.__signal_callbacks
callbacks.add(signal_callback)
@callback
def remove_callback() -> None:
callbacks.discard(signal_callback)
return remove_callback
@@ -2,6 +2,9 @@
"entity_component": {
"_": {
"default": "mdi:led-on"
},
"receiver": {
"default": "mdi:led-off"
}
}
}
@@ -1,10 +1,21 @@
{
"entity_component": {
"_": {
"name": "Infrared emitter"
},
"receiver": {
"name": "Infrared receiver"
}
},
"exceptions": {
"component_not_loaded": {
"message": "Infrared component not loaded"
},
"entity_not_found": {
"message": "Infrared entity `{entity_id}` not found"
},
"receiver_not_found": {
"message": "Infrared receiver entity `{entity_id}` not found"
}
}
}
@@ -77,9 +77,10 @@ async def async_reload(hass: HomeAssistant, service_call: ServiceCall) -> None:
existing_intents = hass.data[DOMAIN]
for intent_type, conf in existing_intents.items():
intent.async_remove(hass, intent_type)
if isinstance(conf.get(CONF_ACTION), script.Script):
await conf[CONF_ACTION].async_unload()
await conf[CONF_ACTION].async_stop()
conf[CONF_ACTION].async_unload()
intent.async_remove(hass, intent_type)
if not new_config or DOMAIN not in new_config:
hass.data[DOMAIN] = {}
@@ -55,6 +55,7 @@ from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
COMPONENTS_WITH_DEMO_PLATFORM = [
Platform.BUTTON,
Platform.FAN,
Platform.EVENT,
Platform.IMAGE,
Platform.INFRARED,
Platform.LAWN_MOWER,
@@ -9,6 +9,7 @@ from homeassistant import data_entry_flow
from homeassistant.components.infrared import (
DOMAIN as INFRARED_DOMAIN,
async_get_emitters,
async_get_receivers,
)
from homeassistant.config_entries import (
ConfigEntry,
@@ -22,7 +23,7 @@ from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import EntitySelector, EntitySelectorConfig
from .const import CONF_INFRARED_ENTITY_ID, DOMAIN
from .const import CONF_INFRARED_ENTITY_ID, CONF_INFRARED_RECEIVER_ENTITY_ID, DOMAIN
CONF_BOOLEAN = "bool"
CONF_INT = "int"
@@ -178,25 +179,33 @@ class InfraredFanSubentryFlowHandler(ConfigSubentryFlow):
) -> SubentryFlowResult:
"""User flow to add an infrared fan."""
entities = async_get_emitters(self.hass)
if not entities:
return self.async_abort(reason="no_emitters")
if user_input is not None:
title = user_input.pop("name")
return self.async_create_entry(data=user_input, title=title)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required("name"): str,
vol.Required(CONF_INFRARED_ENTITY_ID): EntitySelector(
EntitySelectorConfig(
domain=INFRARED_DOMAIN,
include_entities=entities,
)
),
}
emitter_entities = async_get_emitters(self.hass)
if not emitter_entities:
return self.async_abort(reason="no_emitters")
schema_dict: dict[vol.Marker, Any] = {
vol.Required("name"): str,
vol.Required(CONF_INFRARED_ENTITY_ID): EntitySelector(
EntitySelectorConfig(
domain=INFRARED_DOMAIN,
include_entities=emitter_entities,
)
),
)
}
receiver_entities = async_get_receivers(self.hass)
if receiver_entities:
schema_dict[vol.Optional(CONF_INFRARED_RECEIVER_ENTITY_ID)] = (
EntitySelector(
EntitySelectorConfig(
domain=INFRARED_DOMAIN,
include_entities=receiver_entities,
)
)
)
return self.async_show_form(step_id="user", data_schema=vol.Schema(schema_dict))
@@ -6,6 +6,7 @@ from homeassistant.util.hass_dict import HassKey
DOMAIN = "kitchen_sink"
CONF_INFRARED_ENTITY_ID = "infrared_entity_id"
CONF_INFRARED_RECEIVER_ENTITY_ID = "infrared_receiver_entity_id"
DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
f"{DOMAIN}.backup_agent_listeners"
)
@@ -0,0 +1,126 @@
"""Demo platform that offers a fake infrared receiver event entity."""
from homeassistant.components.event import EventEntity
from homeassistant.components.infrared import (
InfraredReceivedSignal,
async_subscribe_receiver,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import (
CALLBACK_TYPE,
Event,
EventStateChangedData,
HomeAssistant,
callback,
)
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event
from .const import CONF_INFRARED_RECEIVER_ENTITY_ID, DOMAIN
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the demo infrared event platform."""
for subentry_id, subentry in config_entry.subentries.items():
if subentry.subentry_type != "infrared_fan":
continue
if subentry.data.get(CONF_INFRARED_RECEIVER_ENTITY_ID) is None:
continue
async_add_entities(
[
DemoInfraredEvent(
subentry_id=subentry_id,
device_name=subentry.title,
infrared_receiver_entity_id=subentry.data[
CONF_INFRARED_RECEIVER_ENTITY_ID
],
)
],
config_subentry_id=subentry_id,
)
class DemoInfraredEvent(EventEntity):
"""Representation of a demo infrared event entity."""
_attr_has_entity_name = True
_attr_name = "Received IR Event"
_attr_should_poll = False
_attr_event_types = ["unknown"]
def __init__(
self, subentry_id: str, device_name: str, infrared_receiver_entity_id: str
) -> None:
"""Initialize the demo infrared event entity."""
self._receiver_entity_id = infrared_receiver_entity_id
self._attr_unique_id = subentry_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, subentry_id)}, name=device_name
)
async def async_added_to_hass(self) -> None:
"""Subscribe to the IR receiver when added to hass."""
await super().async_added_to_hass()
@callback
def _handle_signal(signal: InfraredReceivedSignal) -> None:
"""Handle a received IR signal."""
self._trigger_event("unknown", {"raw_code": signal.timings})
self.async_write_ha_state()
remove_signal_subscription: CALLBACK_TYPE | None = None
@callback
def _async_unsubscribe_receiver() -> None:
"""Unsubscribe from the current IR receiver."""
nonlocal remove_signal_subscription
if remove_signal_subscription is None:
return
remove_signal_subscription()
remove_signal_subscription = None
@callback
def _async_update_receiver_subscription(write_state: bool = True) -> None:
"""Update the IR receiver subscription when availability changes."""
nonlocal remove_signal_subscription
ir_state = self.hass.states.get(self._receiver_entity_id)
receiver_available = (
ir_state is not None and ir_state.state != STATE_UNAVAILABLE
)
if not receiver_available:
_async_unsubscribe_receiver()
elif remove_signal_subscription is None:
remove_signal_subscription = async_subscribe_receiver(
self.hass, self._receiver_entity_id, _handle_signal
)
if self._attr_available == receiver_available:
return
self._attr_available = receiver_available
if write_state:
self.async_write_ha_state()
@callback
def _async_ir_state_changed(event: Event[EventStateChangedData]) -> None:
"""Handle infrared entity state changes."""
_async_update_receiver_subscription()
_async_update_receiver_subscription(write_state=False)
self.async_on_remove(_async_unsubscribe_receiver)
self.async_on_remove(
async_track_state_change_event(
self.hass, [self._receiver_entity_id], _async_ir_state_changed
)
)
@@ -3,16 +3,27 @@
import infrared_protocols
from homeassistant.components import persistent_notification
from homeassistant.components.infrared import InfraredEntity
from homeassistant.components.infrared import (
InfraredEmitterEntity,
InfraredReceivedSignal,
InfraredReceiverEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
PARALLEL_UPDATES = 0
INFRARED_COMMAND_SIGNAL = f"{DOMAIN}_infrared_command_signal"
async def async_setup_entry(
hass: HomeAssistant,
@@ -22,37 +33,60 @@ async def async_setup_entry(
"""Set up the demo infrared platform."""
async_add_entities(
[
DemoInfrared(
unique_id="ir_transmitter",
device_name="IR Blaster",
entity_name="Infrared Transmitter",
DemoInfraredEmitter(
unique_id="ir_emitter",
entity_name="Infrared Emitter",
),
DemoInfraredReceiver(
unique_id="ir_receiver",
entity_name="Infrared Receiver",
),
]
)
class DemoInfrared(InfraredEntity):
# pylint: disable=hass-enforce-class-module
class DemoInfraredEntityBase(Entity):
"""Representation of a demo infrared entity."""
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(
self,
unique_id: str,
device_name: str,
entity_name: str,
) -> None:
def __init__(self, unique_id: str, entity_name: str) -> None:
"""Initialize the demo infrared entity."""
super().__init__()
self._attr_unique_id = unique_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
name=device_name,
identifiers={(DOMAIN, "infrared")}, name="IR Blaster"
)
self._attr_name = entity_name
class DemoInfraredEmitter(DemoInfraredEntityBase, InfraredEmitterEntity):
"""Representation of a demo infrared emitter entity."""
async def async_send_command(self, command: infrared_protocols.Command) -> None:
"""Send an IR command."""
raw_timings = command.get_raw_timings()
persistent_notification.async_create(
self.hass, str(command.get_raw_timings()), title="Infrared Command"
self.hass, str(raw_timings), title="Infrared Command Sent"
)
async_dispatcher_send(self.hass, INFRARED_COMMAND_SIGNAL, raw_timings)
class DemoInfraredReceiver(DemoInfraredEntityBase, InfraredReceiverEntity):
"""Representation of a demo infrared receiver entity."""
@callback
def _on_dispatcher_signal(self, raw_timings: list[int]) -> None:
"""Handle received infrared command signal."""
self._handle_received_signal(InfraredReceivedSignal(timings=raw_timings))
async def async_added_to_hass(self) -> None:
"""Called when entity is added to hass."""
await super().async_added_to_hass()
self.async_on_remove(
async_dispatcher_connect(
self.hass, INFRARED_COMMAND_SIGNAL, self._on_dispatcher_signal
)
)
@@ -35,7 +35,7 @@
},
"infrared_fan": {
"abort": {
"no_emitters": "No infrared transmitter entities found. Please set up an infrared device first."
"no_emitters": "No infrared emitter entities found. Please set up an infrared device first."
},
"entry_type": "Infrared fan",
"initiate_flow": {
@@ -44,10 +44,11 @@
"step": {
"user": {
"data": {
"infrared_entity_id": "Infrared transmitter",
"infrared_entity_id": "Infrared emitter",
"infrared_receiver_entity_id": "Infrared receiver",
"name": "[%key:common::config_flow::data::name%]"
},
"description": "Select an infrared transmitter to control the fan."
"description": "Select an infrared emitter to control the fan."
}
}
}
@@ -1,90 +0,0 @@
{
"entity": {
"button": {
"back": {
"default": "mdi:keyboard-backspace"
},
"down": {
"default": "mdi:arrow-down"
},
"exit": {
"default": "mdi:exit-to-app"
},
"guide": {
"default": "mdi:television-guide"
},
"hdmi_1": {
"default": "mdi:video-input-hdmi"
},
"hdmi_2": {
"default": "mdi:video-input-hdmi"
},
"hdmi_3": {
"default": "mdi:video-input-hdmi"
},
"hdmi_4": {
"default": "mdi:video-input-hdmi"
},
"home": {
"default": "mdi:home"
},
"info": {
"default": "mdi:information-outline"
},
"input": {
"default": "mdi:import"
},
"left": {
"default": "mdi:arrow-left"
},
"menu": {
"default": "mdi:menu"
},
"num_0": {
"default": "mdi:numeric-0"
},
"num_1": {
"default": "mdi:numeric-1"
},
"num_2": {
"default": "mdi:numeric-2"
},
"num_3": {
"default": "mdi:numeric-3"
},
"num_4": {
"default": "mdi:numeric-4"
},
"num_5": {
"default": "mdi:numeric-5"
},
"num_6": {
"default": "mdi:numeric-6"
},
"num_7": {
"default": "mdi:numeric-7"
},
"num_8": {
"default": "mdi:numeric-8"
},
"num_9": {
"default": "mdi:numeric-9"
},
"ok": {
"default": "mdi:check"
},
"power_off": {
"default": "mdi:power-off"
},
"power_on": {
"default": "mdi:power-on"
},
"right": {
"default": "mdi:arrow-right"
},
"up": {
"default": "mdi:arrow-up"
}
}
}
}
+2 -8
View File
@@ -251,10 +251,8 @@ class MatterFan(MatterEntity, FanEntity):
return
self._feature_map = feature_map
self._attr_supported_features = FanEntityFeature(0)
# Reset to default so a featuremap change from MultiSpeed -> non-MultiSpeed
# does not leave a stale speed_count / percentage_step.
self._attr_speed_count = 100
if feature_map & FanControlFeature.kMultiSpeed:
self._attr_supported_features |= FanEntityFeature.SET_SPEED
self._attr_speed_count = int(
self.get_matter_attribute_value(clusters.FanControl.Attributes.SpeedMax)
)
@@ -304,12 +302,8 @@ class MatterFan(MatterEntity, FanEntity):
if feature_map & FanControlFeature.kAirflowDirection:
self._attr_supported_features |= FanEntityFeature.DIRECTION
# PercentSetting is always a mandatory attribute of the FanControl cluster,
# so percentage-based speed control is always available.
self._attr_supported_features |= (
FanEntityFeature.SET_SPEED
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON
FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON
)
@@ -1,108 +1,11 @@
"""Provides conditions for media players."""
from datetime import datetime
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import Condition, make_entity_state_condition
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import (
Condition,
EntityConditionBase,
EntityNumericalConditionBase,
make_entity_state_condition,
)
from . import ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED
from .const import DOMAIN, MediaPlayerState
class _MediaPlayerMutedConditionBase(EntityConditionBase):
"""Base class for media player is_muted/is_unmuted conditions."""
_domain_specs = {DOMAIN: DomainSpec()}
_target_muted: bool
def _state_valid_since(self, state: State) -> datetime:
"""Anchor `for:` durations to `last_updated` for the muted attribute.
Needed because the domain spec does not reflect that the condition
reads from the muted and volume attributes.
"""
return state.last_updated
def _has_volume_attributes(self, state: State) -> bool:
"""Check if the state has volume muted or volume level attributes."""
return (
state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is not None
or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None
)
def _should_include(self, state: State) -> bool:
"""Skip entities without volume attributes from the all/count check."""
return super()._should_include(state) and self._has_volume_attributes(state)
def _is_muted(self, state: State) -> bool:
"""Check if the media player is muted."""
return (
state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is True
or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) == 0
)
def is_valid_state(self, entity_state: State) -> bool:
"""Check if the entity state matches the targeted muted state."""
if not self._has_volume_attributes(entity_state):
return False
return self._is_muted(entity_state) is self._target_muted
class MediaPlayerIsMutedCondition(_MediaPlayerMutedConditionBase):
"""Condition that passes when the media player is muted."""
_target_muted = True
class MediaPlayerIsUnmutedCondition(_MediaPlayerMutedConditionBase):
"""Condition that passes when the media player is not muted."""
_target_muted = False
class MediaPlayerIsVolumeCondition(EntityNumericalConditionBase):
"""Condition for media player volume level with 0.0-1.0 to percentage conversion."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_MEDIA_VOLUME_LEVEL)}
_valid_unit = "%"
def _get_tracked_value(self, entity_state: State) -> Any:
"""Get the volume value converted from 0.0-1.0 to percentage (0-100)."""
raw = super()._get_tracked_value(entity_state)
if raw is None:
return None
try:
return float(raw) * 100.0
except TypeError, ValueError:
return None
def _should_include(self, state: State) -> bool:
"""Skip media players that do not expose a volume_level attribute."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None
)
CONDITIONS: dict[str, type[Condition]] = {
"is_muted": MediaPlayerIsMutedCondition,
"is_not_playing": make_entity_state_condition(
DOMAIN,
{
MediaPlayerState.BUFFERING,
MediaPlayerState.IDLE,
MediaPlayerState.OFF,
MediaPlayerState.ON,
MediaPlayerState.PAUSED,
},
),
"is_off": make_entity_state_condition(DOMAIN, MediaPlayerState.OFF),
"is_on": make_entity_state_condition(
DOMAIN,
@@ -114,10 +17,18 @@ CONDITIONS: dict[str, type[Condition]] = {
MediaPlayerState.PLAYING,
},
),
"is_not_playing": make_entity_state_condition(
DOMAIN,
{
MediaPlayerState.BUFFERING,
MediaPlayerState.IDLE,
MediaPlayerState.OFF,
MediaPlayerState.ON,
MediaPlayerState.PAUSED,
},
),
"is_paused": make_entity_state_condition(DOMAIN, MediaPlayerState.PAUSED),
"is_playing": make_entity_state_condition(DOMAIN, MediaPlayerState.PLAYING),
"is_unmuted": MediaPlayerIsUnmutedCondition,
"is_volume": MediaPlayerIsVolumeCondition,
}
@@ -1,51 +1,22 @@
.condition_common: &condition_common
target: &condition_media_player_target
target:
entity:
domain: media_player
fields:
behavior: &condition_behavior
behavior:
required: true
default: any
selector:
automation_behavior:
mode: condition
for: &condition_for
for:
required: true
default: 00:00:00
selector:
duration:
.volume_threshold_entity: &volume_threshold_entity
- domain: input_number
unit_of_measurement: "%"
- domain: number
unit_of_measurement: "%"
- domain: sensor
unit_of_measurement: "%"
.volume_threshold_number: &volume_threshold_number
min: 0
max: 100
mode: box
unit_of_measurement: "%"
is_muted: *condition_common
is_off: *condition_common
is_on: *condition_common
is_not_playing: *condition_common
is_paused: *condition_common
is_playing: *condition_common
is_unmuted: *condition_common
is_volume:
target: *condition_media_player_target
fields:
behavior: *condition_behavior
for: *condition_for
threshold:
required: true
selector:
numeric_threshold:
entity: *volume_threshold_entity
mode: is
number: *volume_threshold_number
@@ -1,8 +1,5 @@
{
"conditions": {
"is_muted": {
"condition": "mdi:volume-mute"
},
"is_not_playing": {
"condition": "mdi:stop"
},
@@ -17,12 +14,6 @@
},
"is_playing": {
"condition": "mdi:play"
},
"is_unmuted": {
"condition": "mdi:volume-high"
},
"is_volume": {
"condition": "mdi:volume-medium"
}
},
"entity_component": {
@@ -152,12 +143,6 @@
},
"unmuted": {
"trigger": "mdi:volume-high"
},
"volume_changed": {
"trigger": "mdi:volume-medium"
},
"volume_crossed_threshold": {
"trigger": "mdi:volume-medium"
}
}
}
@@ -2,24 +2,10 @@
"common": {
"condition_behavior_name": "Condition passes if",
"condition_for_name": "For at least",
"condition_threshold_name": "Threshold",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least",
"trigger_threshold_name": "Threshold"
"trigger_for_name": "For at least"
},
"conditions": {
"is_muted": {
"description": "Tests if one or more media players are muted.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::media_player::common::condition_for_name%]"
}
},
"name": "Media player is muted"
},
"is_not_playing": {
"description": "Tests if one or more media players are not playing.",
"fields": {
@@ -79,33 +65,6 @@
}
},
"name": "Media player is playing"
},
"is_unmuted": {
"description": "Tests if one or more media players are not muted.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::media_player::common::condition_for_name%]"
}
},
"name": "Media player is not muted"
},
"is_volume": {
"description": "Tests the volume of one or more media players.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::media_player::common::condition_for_name%]"
},
"threshold": {
"name": "[%key:component::media_player::common::condition_threshold_name%]"
}
},
"name": "Volume"
}
},
"device_automation": {
@@ -561,30 +520,6 @@
}
},
"name": "Media player unmuted"
},
"volume_changed": {
"description": "Triggers after the volume of one or more media players changes.",
"fields": {
"threshold": {
"name": "[%key:component::media_player::common::trigger_threshold_name%]"
}
},
"name": "Media player volume changed"
},
"volume_crossed_threshold": {
"description": "Triggers after the volume of one or more media players crosses a threshold.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::media_player::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::media_player::common::trigger_threshold_name%]"
}
},
"name": "Media player volume crossed threshold"
}
}
}
@@ -1,11 +1,9 @@
"""Provides triggers for media players."""
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
EntityNumericalStateChangedTriggerBase,
EntityNumericalStateCrossedThresholdTriggerBase,
EntityNumericalStateTriggerBase,
EntityTriggerBase,
Trigger,
make_entity_transition_trigger,
@@ -14,10 +12,6 @@ from homeassistant.helpers.trigger import (
from . import ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, MediaPlayerState
from .const import DOMAIN
VOLUME_DOMAIN_SPECS = {
DOMAIN: DomainSpec(value_source=ATTR_MEDIA_VOLUME_LEVEL),
}
class _MediaPlayerMutedStateTriggerBase(EntityTriggerBase):
"""Base class for media player muted/unmuted triggers."""
@@ -39,7 +33,27 @@ class _MediaPlayerMutedStateTriggerBase(EntityTriggerBase):
excluded from the check - otherwise an "all" check would never
pass when there are media players without volume support.
"""
return super()._should_include(state) and self._has_volume_attributes(state)
return state.state not in self._excluded_states and self._has_volume_attributes(
state
)
def check_all_match(self, entity_ids: set[str]) -> bool:
"""Check if all mutable entity states match."""
return all(
self.is_valid_state(state)
for entity_id in entity_ids
if (state := self._hass.states.get(entity_id)) is not None
and self._should_include(state)
)
def count_matches(self, entity_ids: set[str]) -> int:
"""Count matching mutable entities."""
return sum(
self.is_valid_state(state)
for entity_id in entity_ids
if (state := self._hass.states.get(entity_id)) is not None
and self._should_include(state)
)
def is_muted(self, state: State) -> bool:
"""Check if the media player is muted."""
@@ -49,7 +63,10 @@ class _MediaPlayerMutedStateTriggerBase(EntityTriggerBase):
)
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check that the muted-state changed."""
"""Check if the origin state is valid and the state has changed."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
if not self._has_volume_attributes(to_state):
return False
@@ -74,48 +91,9 @@ class MediaPlayerUnmutedTrigger(_MediaPlayerMutedStateTriggerBase):
_target_muted = False
class VolumeTriggerMixin(EntityNumericalStateTriggerBase):
"""Mixin for volume triggers."""
_domain_specs = VOLUME_DOMAIN_SPECS
_valid_unit = "%"
def _get_tracked_value(self, state: State) -> float | None:
"""Get tracked volume as a percentage."""
value = super()._get_tracked_value(state)
if value is None:
return None
# Convert 0.0-1.0 range to percentage (0-100)
return value * 100.0
def _should_include(self, state: State) -> bool:
"""Check if an entity should participate in all/count checks.
Entities without a volume level cannot have their volume tracked,
so they are excluded - otherwise an "all" check would never pass
when there are media players without volume support.
"""
return (
super()._should_include(state)
and state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None
)
class VolumeChangedTrigger(EntityNumericalStateChangedTriggerBase, VolumeTriggerMixin):
"""Trigger for media player volume changes."""
class VolumeCrossedThresholdTrigger(
EntityNumericalStateCrossedThresholdTriggerBase, VolumeTriggerMixin
):
"""Trigger for media player volume crossing a threshold."""
TRIGGERS: dict[str, type[Trigger]] = {
"muted": MediaPlayerMutedTrigger,
"unmuted": MediaPlayerUnmutedTrigger,
"volume_changed": VolumeChangedTrigger,
"volume_crossed_threshold": VolumeCrossedThresholdTrigger,
"paused_playing": make_entity_transition_trigger(
DOMAIN,
from_states={
@@ -1,34 +1,20 @@
.trigger_common: &trigger_common
target: &trigger_media_player_target
target:
entity:
domain: media_player
fields:
behavior: &trigger_behavior
behavior:
required: true
default: any
selector:
automation_behavior:
mode: trigger
for: &trigger_for
for:
required: true
default: 00:00:00
selector:
duration:
.volume_threshold_entity: &volume_threshold_entity
- domain: input_number
unit_of_measurement: "%"
- domain: number
unit_of_measurement: "%"
- domain: sensor
unit_of_measurement: "%"
.volume_threshold_number: &volume_threshold_number
min: 0
max: 100
mode: box
unit_of_measurement: "%"
muted: *trigger_common
unmuted: *trigger_common
paused_playing: *trigger_common
@@ -36,27 +22,3 @@ started_playing: *trigger_common
stopped_playing: *trigger_common
turned_off: *trigger_common
turned_on: *trigger_common
volume_changed:
target: *trigger_media_player_target
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *volume_threshold_entity
mode: changed
number: *volume_threshold_number
volume_crossed_threshold:
target: *trigger_media_player_target
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
numeric_threshold:
entity: *volume_threshold_entity
mode: crossed
number: *volume_threshold_number
+13 -10
View File
@@ -5,31 +5,30 @@ from datetime import timedelta
from mill import Mill
from mill_local import Mill as MillLocal
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CLOUD, CONNECTION_TYPE, DOMAIN, LOCAL
from .coordinator import (
MillConfigEntry,
MillDataUpdateCoordinator,
MillHistoricDataUpdateCoordinator,
)
from .coordinator import MillDataUpdateCoordinator, MillHistoricDataUpdateCoordinator
PLATFORMS = [Platform.CLIMATE, Platform.NUMBER, Platform.SENSOR]
__all__ = ["CLOUD", "CONNECTION_TYPE", "DOMAIN", "LOCAL"]
async def async_setup_entry(hass: HomeAssistant, entry: MillConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up the Mill heater."""
hass.data.setdefault(DOMAIN, {LOCAL: {}, CLOUD: {}})
if entry.data.get(CONNECTION_TYPE) == LOCAL:
mill_data_connection = MillLocal(
entry.data[CONF_IP_ADDRESS],
websession=async_get_clientsession(hass),
)
update_interval = timedelta(seconds=15)
key = entry.data[CONF_IP_ADDRESS]
conn_type = LOCAL
else:
mill_data_connection = Mill(
entry.data[CONF_USERNAME],
@@ -37,6 +36,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: MillConfigEntry) -> bool
websession=async_get_clientsession(hass),
)
update_interval = timedelta(seconds=30)
key = entry.data[CONF_USERNAME]
conn_type = CLOUD
historic_data_coordinator = MillHistoricDataUpdateCoordinator(
hass,
@@ -55,12 +56,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: MillConfigEntry) -> bool
)
await data_coordinator.async_config_entry_first_refresh()
entry.runtime_data = data_coordinator
# Uses legacy hass.data[DOMAIN] pattern
# pylint: disable-next=hass-use-runtime-data
hass.data[DOMAIN][conn_type][key] = data_coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: MillConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+15 -5
View File
@@ -1,4 +1,5 @@
"""Support for mill wifi-enabled home heaters."""
# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
from typing import Any
@@ -13,7 +14,14 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_TEMPERATURE,
CONF_IP_ADDRESS,
CONF_USERNAME,
PRECISION_TENTHS,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
@@ -25,6 +33,7 @@ from .const import (
ATTR_COMFORT_TEMP,
ATTR_ROOM_NAME,
ATTR_SLEEP_TEMP,
CLOUD,
CONNECTION_TYPE,
DOMAIN,
LOCAL,
@@ -33,7 +42,7 @@ from .const import (
MIN_TEMP,
SERVICE_SET_ROOM_TEMP,
)
from .coordinator import MillConfigEntry, MillDataUpdateCoordinator
from .coordinator import MillDataUpdateCoordinator
from .entity import MillBaseEntity
SET_ROOM_TEMP_SCHEMA = vol.Schema(
@@ -48,16 +57,17 @@ SET_ROOM_TEMP_SCHEMA = vol.Schema(
async def async_setup_entry(
hass: HomeAssistant,
entry: MillConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Mill climate."""
mill_data_coordinator = entry.runtime_data
if entry.data.get(CONNECTION_TYPE) == LOCAL:
mill_data_coordinator = hass.data[DOMAIN][LOCAL][entry.data[CONF_IP_ADDRESS]]
async_add_entities([LocalMillHeater(mill_data_coordinator)])
return
mill_data_coordinator = hass.data[DOMAIN][CLOUD][entry.data[CONF_USERNAME]]
entities = [
MillHeater(mill_data_coordinator, mill_device)
for mill_device in mill_data_coordinator.data.values()
@@ -57,9 +57,6 @@ class MillDataUpdateCoordinator(DataUpdateCoordinator):
)
type MillConfigEntry = ConfigEntry[MillDataUpdateCoordinator]
class MillHistoricDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to manage fetching Mill historic data."""
+10 -5
View File
@@ -3,23 +3,28 @@
from mill import Heater, MillDevice
from homeassistant.components.number import NumberDeviceClass, NumberEntity
from homeassistant.const import UnitOfPower
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_USERNAME, UnitOfPower
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CLOUD, CONNECTION_TYPE
from .coordinator import MillConfigEntry, MillDataUpdateCoordinator
from .const import CLOUD, CONNECTION_TYPE, DOMAIN
from .coordinator import MillDataUpdateCoordinator
from .entity import MillBaseEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: MillConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Mill Number."""
if entry.data.get(CONNECTION_TYPE) == CLOUD:
mill_data_coordinator = entry.runtime_data
# Uses legacy hass.data[DOMAIN] pattern
# pylint: disable-next=hass-use-runtime-data
mill_data_coordinator: MillDataUpdateCoordinator = hass.data[DOMAIN][CLOUD][
entry.data[CONF_USERNAME]
]
async_add_entities(
MillNumber(mill_data_coordinator, mill_device)
+12 -4
View File
@@ -1,4 +1,5 @@
"""Support for mill wifi-enabled home heaters."""
# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
import mill
@@ -8,9 +9,12 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
CONF_IP_ADDRESS,
CONF_USERNAME,
PERCENTAGE,
EntityCategory,
UnitOfEnergy,
@@ -25,9 +29,11 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
BATTERY,
CLOUD,
CONNECTION_TYPE,
CONSUMPTION_TODAY,
CONSUMPTION_YEAR,
DOMAIN,
ECO2,
HUMIDITY,
LOCAL,
@@ -35,7 +41,7 @@ from .const import (
TEMPERATURE,
TVOC,
)
from .coordinator import MillConfigEntry, MillDataUpdateCoordinator
from .coordinator import MillDataUpdateCoordinator
from .entity import MillBaseEntity
HEATER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
@@ -140,13 +146,13 @@ SOCKET_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: MillConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Mill sensor."""
mill_data_coordinator = entry.runtime_data
if entry.data.get(CONNECTION_TYPE) == LOCAL:
mill_data_coordinator = hass.data[DOMAIN][LOCAL][entry.data[CONF_IP_ADDRESS]]
async_add_entities(
LocalMillSensor(
mill_data_coordinator,
@@ -156,6 +162,8 @@ async def async_setup_entry(
)
return
mill_data_coordinator = hass.data[DOMAIN][CLOUD][entry.data[CONF_USERNAME]]
entities = [
MillSensor(
mill_data_coordinator,
@@ -1,5 +1,7 @@
"""Mitsubishi Comfort integration for Home Assistant."""
from __future__ import annotations
import asyncio
import logging
@@ -1,5 +1,7 @@
"""Climate entity for Mitsubishi Comfort integration."""
from __future__ import annotations
from typing import Any
from mitsubishi_comfort import FanSpeed, IndoorUnit, Mode, VaneDirection
@@ -1,5 +1,7 @@
"""Config flow for Mitsubishi Comfort integration."""
from __future__ import annotations
import logging
from typing import Any
@@ -1,5 +1,7 @@
"""DataUpdateCoordinator for Mitsubishi Comfort devices."""
from __future__ import annotations
import logging
from mitsubishi_comfort import IndoorUnit, KumoStation
@@ -1,5 +1,7 @@
"""Base entity for Mitsubishi Comfort integration."""
from __future__ import annotations
from mitsubishi_comfort import IndoorUnit, KumoStation
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
@@ -82,7 +82,6 @@ ATTR_SENSOR_UOM = "unit_of_measurement"
SIGNAL_SENSOR_UPDATE = f"{DOMAIN}_sensor_update"
SIGNAL_LOCATION_UPDATE = DOMAIN + "_location_update_{}"
SIGNAL_RECORD_NOTIFICATION = f"{DOMAIN}_record_notification"
ATTR_CAMERA_ENTITY_ID = "camera_entity_id"
+1 -23
View File
@@ -21,13 +21,9 @@ from homeassistant.components.notify import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
@@ -50,7 +46,6 @@ from .const import (
DATA_NOTIFY,
DATA_PUSH_CHANNEL,
DOMAIN,
SIGNAL_RECORD_NOTIFICATION,
)
from .helpers import device_info
from .push_notification import PushChannel
@@ -116,21 +111,6 @@ class MobileAppNotifyEntity(NotifyEntity):
translation_placeholders={"device_name": self._config_entry.title},
)
@callback
def _async_handle_notification(self, webhook_id: str) -> None:
"""Handle notifications triggered externally."""
if webhook_id == self._config_entry.data[ATTR_WEBHOOK_ID]:
self._async_record_notification()
async def async_added_to_hass(self) -> None:
"""Register callback."""
self.async_on_remove(
async_dispatcher_connect(
self.hass, SIGNAL_RECORD_NOTIFICATION, self._async_handle_notification
)
)
def push_registrations(hass: HomeAssistant) -> dict[str, str]:
"""Return a dictionary of push enabled registrations."""
@@ -215,7 +195,6 @@ class MobileAppNotificationService(BaseNotificationService):
data,
partial(self._async_send_remote_message_target, entry),
)
async_dispatcher_send(self.hass, SIGNAL_RECORD_NOTIFICATION, target)
continue
# Test if local push only.
@@ -224,7 +203,6 @@ class MobileAppNotificationService(BaseNotificationService):
continue
await self._async_send_remote_message_target(entry, data)
async_dispatcher_send(self.hass, SIGNAL_RECORD_NOTIFICATION, target)
if failed_targets:
raise HomeAssistantError(
+1 -29
View File
@@ -11,12 +11,7 @@ import voluptuous as vol
from homeassistant import config as conf_util
from homeassistant.components import websocket_api
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_DISCOVERY,
CONF_PLATFORM,
CONF_PROTOCOL,
SERVICE_RELOAD,
)
from homeassistant.const import CONF_DISCOVERY, CONF_PLATFORM, SERVICE_RELOAD
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import (
ConfigValidationError,
@@ -32,7 +27,6 @@ from homeassistant.helpers import (
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import async_get_platforms
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.reload import async_integration_yaml_config
from homeassistant.helpers.service import async_register_admin_service
from homeassistant.helpers.typing import ConfigType
@@ -79,14 +73,12 @@ from .const import (
DEFAULT_DISCOVERY,
DEFAULT_ENCODING,
DEFAULT_PREFIX,
DEFAULT_PROTOCOL,
DEFAULT_QOS,
DEFAULT_RETAIN,
DOMAIN,
ENTITY_PLATFORMS,
ENTRY_OPTION_FIELDS,
MQTT_CONNECTION_STATE,
PROTOCOL_311,
TEMPLATE_ERRORS,
Platform,
)
@@ -432,26 +424,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Load a config entry."""
mqtt_data: MqttData
if (protocol := entry.data.get(CONF_PROTOCOL, PROTOCOL_311)) != DEFAULT_PROTOCOL:
broker: str = entry.data[CONF_BROKER]
async_create_issue(
hass,
DOMAIN,
"protocol_5_migration",
issue_domain=DOMAIN,
is_fixable=True,
breaks_in_ha_version="2027.1.0",
severity=IssueSeverity.WARNING,
learn_more_url="https://www.home-assistant.io/integrations/mqtt/#mqtt-protocol",
data={
"entry_id": entry.entry_id,
"broker": broker,
"protocol": protocol,
},
translation_placeholders={"broker": broker, "protocol": protocol},
translation_key="protocol_5_migration",
)
async def _setup_client() -> tuple[MqttData, dict[str, Any]]:
"""Set up the MQTT client."""
# Fetch configuration
+33 -33
View File
@@ -16,8 +16,6 @@ from typing import TYPE_CHECKING, Any
from uuid import uuid4
import certifi
import paho.mqtt.client as mqtt
from paho.mqtt.matcher import MQTTMatcher
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -42,7 +40,6 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.frame import ReportBehavior, report_usage
from homeassistant.helpers.importlib import async_import_module
from homeassistant.helpers.start import async_at_started
from homeassistant.helpers.typing import ConfigType
@@ -50,7 +47,6 @@ from homeassistant.setup import SetupPhases, async_pause_setup
from homeassistant.util.collection import chunked_or_all
from homeassistant.util.logging import catch_log_exception, log_exception
from .async_client import AsyncMQTTClient
from .const import (
CONF_BIRTH_MESSAGE,
CONF_BROKER,
@@ -67,6 +63,7 @@ from .const import (
DEFAULT_ENCODING,
DEFAULT_KEEPALIVE,
DEFAULT_PORT,
DEFAULT_PROTOCOL,
DEFAULT_QOS,
DEFAULT_TRANSPORT,
DEFAULT_WILL,
@@ -77,7 +74,6 @@ from .const import (
MQTT_PROCESSED_SUBSCRIPTIONS,
PROTOCOL_5,
PROTOCOL_31,
PROTOCOL_311,
TRANSPORT_WEBSOCKETS,
)
from .models import (
@@ -90,6 +86,13 @@ from .models import (
)
from .util import EnsureJobAfterCooldown, get_file_path, mqtt_config_entry_enabled
if TYPE_CHECKING:
# Only import for paho-mqtt type checking here, imports are done locally
# because integrations should be able to optionally rely on MQTT.
import paho.mqtt.client as mqtt
from .async_client import AsyncMQTTClient
_LOGGER = logging.getLogger(__name__)
MIN_BUFFER_SIZE = 131072 # Minimum buffer size to use if preferred size fails
@@ -125,8 +128,8 @@ def publish(
hass: HomeAssistant,
topic: str,
payload: PublishPayloadType,
qos: int = 0,
retain: bool = False,
qos: int | None = 0,
retain: bool | None = False,
encoding: str | None = DEFAULT_ENCODING,
) -> None:
"""Publish message to a MQTT topic."""
@@ -137,8 +140,8 @@ async def async_publish(
hass: HomeAssistant,
topic: str,
payload: PublishPayloadType,
qos: int = 0,
retain: bool = False,
qos: int | None = 0,
retain: bool | None = False,
encoding: str | None = DEFAULT_ENCODING,
) -> None:
"""Publish message to a MQTT topic."""
@@ -178,22 +181,9 @@ async def async_publish(
)
return
# Passing None for qos or retain args was deprecated.
# Custom integrations should update there code.
# Check for fallback to `None` values can be removed with HA Core 2027.6
if qos is None or retain is None:
report_usage( # type: ignore[unreachable]
"that calls the MQTT publish API with `None` for qos or retain. "
"The `qos` argument must be an `int`, "
"and the `retain` argument must be a `bool`",
breaks_in_ha_version="2027.6.0",
core_behavior=ReportBehavior.LOG,
exclude_integrations={DOMAIN},
)
qos = qos or 0
retain = retain or False
await mqtt_data.client.async_publish(topic, outgoing_payload, qos, retain)
await mqtt_data.client.async_publish(
topic, outgoing_payload, qos or 0, retain or False
)
@callback
@@ -333,12 +323,15 @@ class MqttClientSetup:
The setup of the MQTT client should be run in an executor job,
because it accesses files, so it does IO.
"""
# We don't import on the top because some integrations
# should be able to optionally rely on MQTT.
from paho.mqtt import client as mqtt # noqa: PLC0415
from .async_client import AsyncMQTTClient # noqa: PLC0415
config = self._config
clean_session: bool | None = None
# If no protocol setting is set in the config entry data
# we assume the config was migrated from YAML, and the
# protocol version is defaulting to legacy version 3.1.1.
if (protocol := config.get(CONF_PROTOCOL, PROTOCOL_311)) == PROTOCOL_31:
if (protocol := config.get(CONF_PROTOCOL, DEFAULT_PROTOCOL)) == PROTOCOL_31:
proto = mqtt.MQTTv31
clean_session = True
elif protocol == PROTOCOL_5:
@@ -427,10 +420,7 @@ class MQTT:
self.loop = hass.loop
self.config_entry = config_entry
self.conf = conf
# If no protocol setting is set in the config entry data
# we assume the config was migrated from YAML, and the
# protocol version is defaulting to legacy version 3.1.1.
self.is_mqttv5 = conf.get(CONF_PROTOCOL, PROTOCOL_311) == PROTOCOL_5
self.is_mqttv5 = conf.get(CONF_PROTOCOL, DEFAULT_PROTOCOL) == PROTOCOL_5
self._simple_subscriptions: defaultdict[str, set[Subscription]] = defaultdict(
set
@@ -565,6 +555,7 @@ class MQTT:
"""Start the misc periodic."""
assert self._misc_timer is None, "Misc periodic already started"
_LOGGER.debug("%s: Starting client misc loop", self.config_entry.title)
import paho.mqtt.client as mqtt # noqa: PLC0415
# Inner function to avoid having to check late import
# each time the function is called.
@@ -708,6 +699,7 @@ class MQTT:
async def async_connect(self, client_available: asyncio.Future[bool]) -> None:
"""Connect to the host. Does not process messages yet."""
import paho.mqtt.client as mqtt # noqa: PLC0415
result: int | None = None
self._available_future = client_available
@@ -765,6 +757,7 @@ class MQTT:
async def _reconnect_loop(self) -> None:
"""Reconnect to the MQTT server."""
import paho.mqtt.client as mqtt # noqa: PLC0415
while True:
if not self.connected:
@@ -1266,6 +1259,9 @@ class MQTT:
@callback
def _async_handle_callback_exception(self, status: mqtt.MQTTErrorCode) -> None:
"""Handle a callback exception."""
# We don't import on the top because some integrations
# should be able to optionally rely on MQTT.
import paho.mqtt.client as mqtt # noqa: PLC0415
_LOGGER.warning(
"Error returned from MQTT server: %s",
@@ -1310,6 +1306,8 @@ class MQTT:
) -> None:
"""Wait for ACK from broker or raise on error."""
if result_code != 0:
import paho.mqtt.client as mqtt # noqa: PLC0415
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="mqtt_broker_error",
@@ -1356,6 +1354,8 @@ class MQTT:
def _matcher_for_topic(subscription: str) -> Callable[[str], bool]:
from paho.mqtt.matcher import MQTTMatcher # noqa: PLC0415
matcher = MQTTMatcher() # type: ignore[no-untyped-call]
matcher[subscription] = True
+9 -8
View File
@@ -22,7 +22,6 @@ from cryptography.hazmat.primitives.serialization import (
load_pem_private_key,
)
from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate
import paho.mqtt.client as mqtt
import voluptuous as vol
import yaml
@@ -4074,7 +4073,6 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
config: dict[str, Any] = {
CONF_BROKER: addon_discovery_config[CONF_HOST],
CONF_PORT: addon_discovery_config[CONF_PORT],
CONF_PROTOCOL: DEFAULT_PROTOCOL,
CONF_USERNAME: addon_discovery_config.get(CONF_USERNAME),
CONF_PASSWORD: addon_discovery_config.get(CONF_PASSWORD),
CONF_DISCOVERY: DEFAULT_DISCOVERY,
@@ -4303,7 +4301,6 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
if user_input is not None:
data: dict[str, Any] = self._hassio_discovery.copy()
data[CONF_BROKER] = data.pop(CONF_HOST)
data[CONF_PROTOCOL] = DEFAULT_PROTOCOL
can_connect = await self.hass.async_add_executor_job(
try_connection,
data,
@@ -4315,7 +4312,6 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
data={
CONF_BROKER: data[CONF_BROKER],
CONF_PORT: data[CONF_PORT],
CONF_PROTOCOL: DEFAULT_PROTOCOL,
CONF_USERNAME: data.get(CONF_USERNAME),
CONF_PASSWORD: data.get(CONF_PASSWORD),
CONF_DISCOVERY: DEFAULT_DISCOVERY,
@@ -5182,8 +5178,6 @@ async def async_get_broker_settings( # noqa: C901
) -> bool:
"""Additional validation on broker settings for better error messages."""
if CONF_PROTOCOL not in validated_user_input:
validated_user_input[CONF_PROTOCOL] = DEFAULT_PROTOCOL
# Get current certificate settings from config entry
certificate: str | None = (
"auto"
@@ -5372,9 +5366,12 @@ async def async_get_broker_settings( # noqa: C901
description={"suggested_value": current_pass},
)
] = PASSWORD_SELECTOR
# show advanced options checkbox if no defaults
# of the advanced options are overridden
# show advanced options checkbox if requested and
# advanced options are enabled
# or when the defaults of advanced options are overridden
if not advanced_broker_options:
if not flow.show_advanced_options:
return False
fields[
vol.Optional(
ADVANCED_OPTIONS,
@@ -5480,6 +5477,10 @@ def try_connection(
user_input: dict[str, Any],
) -> bool:
"""Test if we can connect to an MQTT broker."""
# We don't import on the top because some integrations
# should be able to optionally rely on MQTT.
import paho.mqtt.client as mqtt # noqa: PLC0415
mqtt_client_setup = MqttClientSetup(user_input)
mqtt_client_setup.setup()
client = mqtt_client_setup.client
+2 -2
View File
@@ -347,14 +347,14 @@ REMOTE_CODE_TEXT = "REMOTE_CODE_TEXT"
PROTOCOL_31 = "3.1"
PROTOCOL_311 = "3.1.1"
PROTOCOL_5 = "5"
SUPPORTED_PROTOCOLS = [PROTOCOL_5, PROTOCOL_311, PROTOCOL_31]
SUPPORTED_PROTOCOLS = [PROTOCOL_31, PROTOCOL_311, PROTOCOL_5]
TRANSPORT_TCP = "tcp"
TRANSPORT_WEBSOCKETS = "websockets"
DEFAULT_PORT = 1883
DEFAULT_KEEPALIVE = 60
DEFAULT_PROTOCOL = PROTOCOL_5
DEFAULT_PROTOCOL = PROTOCOL_311
DEFAULT_TRANSPORT = TRANSPORT_TCP
DEFAULT_BIRTH = {
+2 -2
View File
@@ -9,8 +9,6 @@ from enum import StrEnum
import logging
from typing import TYPE_CHECKING, Any, TypedDict
from paho.mqtt.client import MQTTMessage
from homeassistant.const import ATTR_ENTITY_ID, ATTR_NAME, Platform
from homeassistant.core import CALLBACK_TYPE, callback
from homeassistant.exceptions import ServiceValidationError, TemplateError
@@ -26,6 +24,8 @@ from homeassistant.helpers.typing import (
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from paho.mqtt.client import MQTTMessage
from .client import MQTT, Subscription
from .debug_info import TimestampedPublishMessage
from .device_trigger import Trigger
+8 -63
View File
@@ -6,16 +6,10 @@ import voluptuous as vol
from homeassistant import data_entry_flow
from homeassistant.components.repairs import RepairsFlow
from homeassistant.const import CONF_PORT, CONF_PROTOCOL
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from .config_flow import try_connection
from .const import DEFAULT_PORT, DOMAIN, PROTOCOL_5
URL_MQTT_BROKER_CONFIGURATION = (
"https://www.home-assistant.io/integrations/mqtt/#broker-configuration"
)
from .const import DOMAIN
class MQTTDeviceEntryMigration(RepairsFlow):
@@ -56,55 +50,6 @@ class MQTTDeviceEntryMigration(RepairsFlow):
)
class MQTTProtocolV5Migration(RepairsFlow):
"""Handler to migrate to MQTT protocol version 5."""
def __init__(self, entry_id: str, broker: str, protocol: str) -> None:
"""Initialize the flow."""
self.entry_id = entry_id
self.broker = broker
self.protocol = protocol
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the first step of a fix flow."""
return await self.async_step_confirm()
async def async_step_confirm(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the confirm step of a fix flow."""
if user_input is not None:
entry = self.hass.config_entries.async_get_entry(self.entry_id)
if TYPE_CHECKING:
assert entry is not None
new_entry_data = entry.data.copy()
new_entry_data[CONF_PROTOCOL] = PROTOCOL_5
# Try the connection with protocol version 5
if await self.hass.async_add_executor_job(
try_connection,
{CONF_PORT: DEFAULT_PORT} | new_entry_data,
):
self.hass.config_entries.async_update_entry(entry, data=new_entry_data)
return self.async_create_entry(data={})
return self.async_abort(
reason="mqtt_broker_migration_to_v5_failed",
description_placeholders={
"broker": self.broker,
"protocol": self.protocol,
"url_mqtt_broker_configuration": URL_MQTT_BROKER_CONFIGURATION,
},
)
return self.async_show_form(
step_id="confirm",
data_schema=vol.Schema({}),
description_placeholders={"broker": self.broker, "protocol": self.protocol},
)
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
@@ -113,13 +58,13 @@ async def async_create_fix_flow(
"""Create flow."""
if TYPE_CHECKING:
assert data is not None
entry_id: str = data["entry_id"] # type: ignore[assignment]
if issue_id == "protocol_5_migration":
broker: str = data["broker"] # type: ignore[assignment]
protocol: str = data["protocol"] # type: ignore[assignment]
return MQTTProtocolV5Migration(entry_id, broker, protocol)
subentry_id: str = data["subentry_id"] # type: ignore[assignment]
name: str = data["name"] # type: ignore[assignment]
entry_id = data["entry_id"]
subentry_id = data["subentry_id"]
name = data["name"]
if TYPE_CHECKING:
assert isinstance(entry_id, str)
assert isinstance(subentry_id, str)
assert isinstance(name, str)
return MQTTDeviceEntryMigration(
entry_id=entry_id,
subentry_id=subentry_id,
@@ -1120,20 +1120,6 @@
"description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/config/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue.",
"title": "Invalid config found for MQTT {domain} item"
},
"protocol_5_migration": {
"fix_flow": {
"abort": {
"mqtt_broker_migration_to_v5_failed": "Migrating the broker ({broker}) protocol version from {protocol} to 5 failed, and the migration has been aborted.\n\nYour broker may not support MQTT protocol version 5.\n\nPlease [reconfigure your MQTT broker settings]({url_mqtt_broker_configuration}) or upgrade your broker to support MQTT protocol version 5 to fix this issue."
},
"step": {
"confirm": {
"description": "Home Assistant is migrating to MQTT protocol version 5. The currently configured protocol version for broker {broker} is {protocol}. This protocol version is deprecated, and support for it will be removed.\n\nSubmitting this form will try to migrate your MQTT broker configuration to use protocol version 5 to fix this issue.",
"title": "MQTT protocol change required"
}
}
},
"title": "Deprecated MQTT protocol {protocol} in use"
},
"subentry_migration_discovery": {
"fix_flow": {
"step": {
@@ -67,11 +67,25 @@ OPENING_CATEGORY_TO_DEVICE_CLASS: Final[dict[str | None, BinarySensorDeviceClass
def get_opening_category(netatmo_device: NetatmoDevice) -> str:
"""Helper function to get opening category for doortag."""
"""Helper function to get opening category from Netatmo API raw data."""
return (
getattr(netatmo_device.device, "doortag_category", None)
or DOORTAG_CATEGORY_OTHER
# Iterate through each home in the raw data.
for home in netatmo_device.data_handler.account.raw_data["homes"]:
# Check if the modules list exists for the current home.
if "modules" in home:
# Iterate through each module to find a matching ID.
for module in home["modules"]:
if module["id"] == netatmo_device.device.entity_id:
# We found the matching device. Get its category.
if module.get("category") is not None:
return cast(str, module["category"])
raise ValueError(
f"Device {netatmo_device.device.entity_id} found, "
"but 'category' is missing in raw data."
)
raise ValueError(
f"Device {netatmo_device.device.entity_id} not found in Netatmo raw data."
)
+3 -2
View File
@@ -12,7 +12,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_URL_CONTROL, NETATMO_CREATE_BUTTON
from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice
from .entity import NetatmoModuleEntity
from .helper import device_type_to_str
_LOGGER = logging.getLogger(__name__)
@@ -56,7 +55,9 @@ class NetatmoCoverPreferredPositionButton(NetatmoModuleEntity, ButtonEntity):
},
]
)
self._attr_unique_id = f"{self.device.entity_id}-{device_type_to_str(self.device_type)}-preferred_position"
self._attr_unique_id = (
f"{self.device.entity_id}-{self.device_type}-preferred_position"
)
@callback
def async_update_callback(self) -> None:
+1 -4
View File
@@ -42,7 +42,6 @@ from .const import (
)
from .data_handler import EVENT, HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice
from .entity import NetatmoModuleEntity
from .helper import device_type_to_str
_LOGGER = logging.getLogger(__name__)
@@ -103,9 +102,7 @@ class NetatmoCamera(NetatmoModuleEntity, Camera):
Camera.__init__(self)
super().__init__(netatmo_device)
self._attr_unique_id = (
f"{netatmo_device.device.entity_id}-{device_type_to_str(self.device_type)}"
)
self._attr_unique_id = f"{netatmo_device.device.entity_id}-{self.device_type}"
self._light_state = None
self._publishers.extend(
+1 -4
View File
@@ -54,7 +54,6 @@ from .const import (
)
from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoRoom
from .entity import NetatmoRoomEntity
from .helper import device_type_to_str
_LOGGER = logging.getLogger(__name__)
@@ -220,9 +219,7 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity):
if self.device_type is NA_THERM:
self._attr_hvac_modes.append(HVACMode.OFF)
self._attr_unique_id = (
f"{self.device.entity_id}-{device_type_to_str(self.device_type)}"
)
self._attr_unique_id = f"{self.device.entity_id}-{self.device_type}"
async def async_added_to_hass(self) -> None:
"""Entity created."""
+1 -4
View File
@@ -18,7 +18,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_URL_CONTROL, NETATMO_CREATE_COVER
from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice
from .entity import NetatmoModuleEntity
from .helper import device_type_to_str
_LOGGER = logging.getLogger(__name__)
@@ -71,9 +70,7 @@ class NetatmoCover(NetatmoModuleEntity, CoverEntity):
},
]
)
self._attr_unique_id = (
f"{self.device.entity_id}-{device_type_to_str(self.device_type)}"
)
self._attr_unique_id = f"{self.device.entity_id}-{self.device_type}"
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
+1 -4
View File
@@ -13,7 +13,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_URL_CONTROL, NETATMO_CREATE_FAN
from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice
from .entity import NetatmoModuleEntity
from .helper import device_type_to_str
_LOGGER = logging.getLogger(__name__)
@@ -63,9 +62,7 @@ class NetatmoFan(NetatmoModuleEntity, FanEntity):
]
)
self._attr_unique_id = (
f"{self.device.entity_id}-{device_type_to_str(self.device_type)}"
)
self._attr_unique_id = f"{self.device.entity_id}-{self.device_type}"
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode of the fan."""
@@ -3,16 +3,6 @@
from dataclasses import dataclass
from uuid import UUID, uuid4
from pyatmo.modules.device_types import DeviceType as NetatmoDeviceType
def device_type_to_str(device_type: NetatmoDeviceType) -> str:
"""Convert a device type to a string.
Used to generate backwards compatible unique ids.
"""
return f"{type(device_type).__name__}.{device_type}"
@dataclass
class NetatmoArea:
@@ -12,5 +12,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pyatmo"],
"requirements": ["pyatmo==9.4.0"]
"requirements": ["pyatmo==9.2.3"]
}
+1 -4
View File
@@ -13,7 +13,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_URL_CONTROL, NETATMO_CREATE_SWITCH
from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice
from .entity import NetatmoModuleEntity
from .helper import device_type_to_str
_LOGGER = logging.getLogger(__name__)
@@ -59,9 +58,7 @@ class NetatmoSwitch(NetatmoModuleEntity, SwitchEntity):
},
]
)
self._attr_unique_id = (
f"{self.device.entity_id}-{device_type_to_str(self.device_type)}"
)
self._attr_unique_id = f"{self.device.entity_id}-{self.device_type}"
self._attr_is_on = self.device.on
@callback
@@ -72,6 +72,7 @@ UNSUPPORTED_MODELS: list[str] = [
]
UNSUPPORTED_WEB_SEARCH_MODELS: list[str] = [
"gpt-5-nano",
"gpt-3.5",
"gpt-4-turbo",
"gpt-4.1-nano",
@@ -2,7 +2,7 @@
from collections.abc import Mapping
import logging
from typing import TYPE_CHECKING, Any, Literal
from typing import TYPE_CHECKING, Any
from openai import OpenAIError
from propcache.api import cached_property
@@ -164,15 +164,14 @@ class OpenAITTSEntity(TextToSpeechEntity, OpenAIBaseLLMEntity):
client = self.entry.runtime_data
response_format = options[ATTR_PREFERRED_FORMAT]
if response_format in ("ogg", "oga"):
codec: Literal["mp3", "opus", "aac", "flac", "wav", "pcm"] = "opus"
elif response_format == "raw":
response_format = codec = "pcm"
elif response_format not in self._supported_formats:
response_format = self.default_options[ATTR_PREFERRED_FORMAT]
codec = response_format
else:
codec = response_format
if response_format not in self._supported_formats:
# common aliases
if response_format == "ogg":
response_format = "opus"
elif response_format == "raw":
response_format = "pcm"
else:
response_format = self.default_options[ATTR_PREFERRED_FORMAT]
try:
async with client.audio.speech.with_streaming_response.create(
@@ -181,7 +180,7 @@ class OpenAITTSEntity(TextToSpeechEntity, OpenAIBaseLLMEntity):
input=message,
instructions=str(options.get(CONF_PROMPT)),
speed=options.get(CONF_TTS_SPEED, RECOMMENDED_TTS_SPEED),
response_format=codec,
response_format=response_format,
) as response:
response_data = bytearray()
async for chunk in response.iter_bytes():
@@ -1,5 +1,7 @@
"""Services for the Overkiz integration."""
from __future__ import annotations
import voluptuous as vol
from homeassistant.components.cover import (
@@ -1,28 +0,0 @@
"""Integration for PAJ GPS trackers."""
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from .const import DOMAIN
from .coordinator import PajGpsConfigEntry, PajGpsCoordinator
PLATFORMS: list[Platform] = [Platform.DEVICE_TRACKER]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup_entry(hass: HomeAssistant, entry: PajGpsConfigEntry) -> bool:
"""Set up platform from a ConfigEntry."""
pajgps_coordinator = PajGpsCoordinator(hass, entry)
await pajgps_coordinator.async_config_entry_first_refresh()
entry.runtime_data = pajgps_coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: PajGpsConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -1,92 +0,0 @@
"""Config flow for PAJ GPS Tracker integration."""
import logging
from typing import Any
from aiohttp import ClientError
from pajgps_api import PajGpsApi
from pajgps_api.models.auth import AuthResponse
from pajgps_api.pajgps_api_error import AuthenticationError, TokenRefreshError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_EMAIL): TextSelector(
TextSelectorConfig(
type=TextSelectorType.EMAIL,
autocomplete="email",
)
),
vol.Required(CONF_PASSWORD): TextSelector(
TextSelectorConfig(
type=TextSelectorType.PASSWORD,
autocomplete="current-password",
)
),
}
)
class PajGPSConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow for PAJ GPS Tracker."""
async def _validate_credentials(
self, email: str, password: str
) -> tuple[str | None, AuthResponse | None]:
"""Attempt a real login with the given credentials.
Returns (None, auth) on success, or (error_key, None) on failure.
"""
websession = async_get_clientsession(self.hass)
try:
api = PajGpsApi(email=email, password=password, websession=websession)
auth = await api.login()
except AuthenticationError, TokenRefreshError:
return "invalid_auth", None
except ClientError:
return "cannot_connect", None
except Exception:
_LOGGER.exception("Unexpected error validating PAJ GPS credentials")
return "unknown", None
return None, auth
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initiated by the user."""
errors: dict[str, str] = {}
if user_input is not None:
normalized_email = user_input[CONF_EMAIL].strip().lower()
user_input[CONF_EMAIL] = normalized_email
error, auth = await self._validate_credentials(
user_input[CONF_EMAIL], user_input[CONF_PASSWORD]
)
if error is None and auth is not None:
await self.async_set_unique_id(str(auth.userID))
self._abort_if_unique_id_configured()
return self.async_create_entry(title=normalized_email, data=user_input)
if error is not None:
errors["base"] = error
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA, user_input
),
errors=errors,
)
@@ -1,4 +0,0 @@
"""Constants for the PajGPS integration."""
DOMAIN = "paj_gps"
UPDATE_INTERVAL = 30
@@ -1,107 +0,0 @@
"""DataUpdateCoordinator for the PAJ GPS integration."""
from dataclasses import dataclass
from datetime import timedelta
import logging
from pajgps_api import PajGpsApi
from pajgps_api.models.device import Device
from pajgps_api.models.trackpoint import TrackPoint
from pajgps_api.pajgps_api_error import (
AuthenticationError,
PajGpsApiError,
TokenRefreshError,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, UPDATE_INTERVAL
_LOGGER = logging.getLogger(__name__)
type PajGpsConfigEntry = ConfigEntry[PajGpsCoordinator]
@dataclass
class PajGpsData:
"""Snapshot of all PAJ GPS data for one coordinator tick."""
devices: dict[int, Device]
positions: dict[int, TrackPoint]
class PajGpsCoordinator(DataUpdateCoordinator[PajGpsData]):
"""Coordinator for the PAJ GPS integration."""
config_entry: PajGpsConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: PajGpsConfigEntry,
) -> None:
"""Initialize the coordinator from config-entry data."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=timedelta(seconds=UPDATE_INTERVAL),
config_entry=config_entry,
)
self._email: str = config_entry.data[CONF_EMAIL]
self._user_id: int | None = None
self.api = PajGpsApi(
email=self._email,
password=config_entry.data[CONF_PASSWORD],
websession=async_get_clientsession(hass),
)
@property
def email(self) -> str:
"""Return the account email address for this coordinator."""
return self._email
@property
def user_id(self) -> int | None:
"""Return the user ID obtained from the login response."""
return self._user_id
async def _async_setup(self) -> None:
"""Perform initial and first data refresh."""
try:
auth = await self.api.login()
self._user_id = auth.userID
except (AuthenticationError, TokenRefreshError) as exc:
raise ConfigEntryAuthFailed from exc
except Exception as exc:
raise ConfigEntryNotReady from exc
async def _async_update_data(self) -> PajGpsData:
"""Fetch device list and positions."""
devices: dict[int, Device] = {}
try:
device_list = await self.api.get_devices()
devices = {
device.id: device for device in device_list if device.id is not None
}
except PajGpsApiError as exc:
raise UpdateFailed(f"Failed to fetch device list: {exc}") from exc
device_ids = list(devices.keys())
positions: dict[int, TrackPoint] = {}
if device_ids:
try:
track_points = await self.api.get_all_last_positions(device_ids)
except PajGpsApiError as exc:
raise UpdateFailed(f"Failed to fetch positions: {exc}") from exc
positions = {
tp.iddevice: tp for tp in track_points if tp.iddevice is not None
}
return PajGpsData(devices=devices, positions=positions)
@@ -1,78 +0,0 @@
"""Platform for GPS device tracker integration.
Reads position data from PajGpsCoordinator and exposes it as a TrackerEntity.
"""
import logging
from homeassistant.components.device_tracker import SourceType
from homeassistant.components.device_tracker.config_entry import TrackerEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import PajGpsConfigEntry
from .coordinator import PajGpsCoordinator
from .entity import PajGpsEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: PajGpsConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up PAJ GPS tracker entities from a config entry."""
coordinator = config_entry.runtime_data
known_device_ids: set[int] = set()
@callback
def _async_add_new_devices() -> None:
"""Add entities for any device IDs not yet tracked."""
current_ids = set(coordinator.data.devices.keys())
new_ids = current_ids - known_device_ids
if new_ids:
sorted_new_ids = sorted(new_ids)
async_add_entities(
PajGPSDeviceTracker(coordinator, device_id)
for device_id in sorted_new_ids
)
known_device_ids.update(sorted_new_ids)
_async_add_new_devices()
if not known_device_ids:
_LOGGER.warning("No PAJ GPS devices found to add as trackers")
config_entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices))
class PajGPSDeviceTracker(PajGpsEntity, TrackerEntity):
"""Tracker entity that reads position from the coordinator snapshot."""
_attr_name = None
_attr_icon = "mdi:map-marker"
def __init__(self, pajgps_coordinator: PajGpsCoordinator, device_id: int) -> None:
"""Initialize the GPS position tracker entity."""
super().__init__(pajgps_coordinator, device_id)
self._attr_unique_id = f"{pajgps_coordinator.user_id}_{device_id}"
@property
def latitude(self) -> float | None:
"""Return the latitude of the device."""
tp = self.coordinator.data.positions.get(self._device_id)
return float(tp.lat) if tp and tp.lat is not None else None
@property
def longitude(self) -> float | None:
"""Return the longitude of the device."""
tp = self.coordinator.data.positions.get(self._device_id)
return float(tp.lng) if tp and tp.lng is not None else None
@property
def source_type(self) -> SourceType:
"""Return the source type of the tracker."""
return SourceType.GPS

Some files were not shown because too many files have changed in this diff Show More