mirror of
https://github.com/home-assistant/core.git
synced 2026-05-07 18:36:39 +02:00
Compare commits
11 Commits
dev
..
ir_receiver
| Author | SHA1 | Date | |
|---|---|---|---|
| 07f1ce7fe2 | |||
| 2b65c8c992 | |||
| 7a7b0e294c | |||
| a9bcf42388 | |||
| 309afb3efb | |||
| 7e7590c8e2 | |||
| 49ab12c950 | |||
| 5d65d3e27b | |||
| 7eeea9060d | |||
| 4086d43a1b | |||
| 62dc48ddd3 |
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"domain": "sensereo",
|
||||
"name": "Sensereo",
|
||||
"iot_standards": ["matter"]
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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]] = {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user