mirror of
https://github.com/home-assistant/core.git
synced 2026-03-31 04:46:09 +02:00
Compare commits
94 Commits
setup_todo
...
PIRUnoccup
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b3f647494 | ||
|
|
85c7bf1dff | ||
|
|
894e9bab0a | ||
|
|
b39c83efd2 | ||
|
|
e855b92b82 | ||
|
|
30ee28a0d3 | ||
|
|
78f6b934bb | ||
|
|
fbef3b27bd | ||
|
|
646f56d015 | ||
|
|
f82d21886a | ||
|
|
f5054d41e1 | ||
|
|
53f64bff49 | ||
|
|
65cb9b8528 | ||
|
|
ecd16d759a | ||
|
|
8498e2a715 | ||
|
|
4fa4ba5ad0 | ||
|
|
a953b697ce | ||
|
|
c543743245 | ||
|
|
5b76fab646 | ||
|
|
6153705b61 | ||
|
|
8632420b8f | ||
|
|
4f89715453 | ||
|
|
8ca8c2191f | ||
|
|
cb43950ccf | ||
|
|
ddfef18183 | ||
|
|
ac65ba7d20 | ||
|
|
d76272d74a | ||
|
|
8e5daeb7dd | ||
|
|
5d7abae490 | ||
|
|
f875c77af0 | ||
|
|
c00a68383c | ||
|
|
5544157d5e | ||
|
|
70aa58913d | ||
|
|
cc363e4ebd | ||
|
|
8d28b399b0 | ||
|
|
fe76fe5408 | ||
|
|
a7de418213 | ||
|
|
e359a8952b | ||
|
|
0a9d4ef138 | ||
|
|
5620cfbfd8 | ||
|
|
fb65cf48c9 | ||
|
|
7fd7b2c203 | ||
|
|
69e691f042 | ||
|
|
f690e6de6a | ||
|
|
ee3c2e6f80 | ||
|
|
5ffe301384 | ||
|
|
e5ad6092d1 | ||
|
|
bd79958d10 | ||
|
|
fe485f853f | ||
|
|
3c67c6087a | ||
|
|
cb7f9b5f49 | ||
|
|
2547563e8c | ||
|
|
213b370693 | ||
|
|
2c9ecb394d | ||
|
|
f71ed51a1a | ||
|
|
9cadf32e36 | ||
|
|
d44387e36b | ||
|
|
1824ef12bb | ||
|
|
c706e8a5b8 | ||
|
|
5bd9742eb3 | ||
|
|
26f3eb5f6d | ||
|
|
7a34d4f881 | ||
|
|
e0a37a5eeb | ||
|
|
ec3d1fd72c | ||
|
|
4edea21cb7 | ||
|
|
7f065c1942 | ||
|
|
46ce07a9a1 | ||
|
|
5807db2c60 | ||
|
|
85732543b2 | ||
|
|
054c61d73f | ||
|
|
be2c20c624 | ||
|
|
706127c9ea | ||
|
|
b163829970 | ||
|
|
7a93eb779c | ||
|
|
7d673cd9c4 | ||
|
|
44bc11580d | ||
|
|
c23795fe14 | ||
|
|
bf6f9a011b | ||
|
|
1cdbe596fe | ||
|
|
a9d52bfbe7 | ||
|
|
6eed1f9961 | ||
|
|
149607ab17 | ||
|
|
279b5be357 | ||
|
|
82b93e788b | ||
|
|
555813f84f | ||
|
|
ecf1b4e591 | ||
|
|
e17a9f12a1 | ||
|
|
e8f05f5291 | ||
|
|
a5a76e9268 | ||
|
|
edc3fb47b2 | ||
|
|
f1e514a70a | ||
|
|
5632baca5b | ||
|
|
78f9bad706 | ||
|
|
3fdaaecd0f |
@@ -468,6 +468,7 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> bool:
|
||||
translation.async_setup(hass)
|
||||
|
||||
recovery = hass.config.recovery_mode
|
||||
device_registry.async_setup(hass)
|
||||
try:
|
||||
await asyncio.gather(
|
||||
create_eager_task(get_internal_store_manager(hass).async_initialize()),
|
||||
|
||||
@@ -13,6 +13,9 @@ from homeassistant.helpers import (
|
||||
config_entry_oauth2_flow,
|
||||
device_registry as dr,
|
||||
)
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
)
|
||||
|
||||
from . import api
|
||||
from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION, DOMAIN
|
||||
@@ -25,11 +28,17 @@ async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: AladdinConnectConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Aladdin Connect Genie from a config entry."""
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
try:
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
)
|
||||
)
|
||||
)
|
||||
except ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@
|
||||
"close_door_failed": {
|
||||
"message": "Failed to close the garage door"
|
||||
},
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
},
|
||||
"open_door_failed": {
|
||||
"message": "Failed to open the garage door"
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==13.3.0"]
|
||||
"requirements": ["aioamazondevices==13.3.1"]
|
||||
}
|
||||
|
||||
@@ -142,6 +142,13 @@ class WellKnownOAuthInfoView(HomeAssistantView):
|
||||
"authorization_endpoint": f"{url_prefix}/auth/authorize",
|
||||
"token_endpoint": f"{url_prefix}/auth/token",
|
||||
"revocation_endpoint": f"{url_prefix}/auth/revoke",
|
||||
# Home Assistant already accepts URL-based client_ids via
|
||||
# IndieAuth without prior registration, which is compatible with
|
||||
# draft-ietf-oauth-client-id-metadata-document. This flag
|
||||
# advertises that support to encourage clients to use it. The
|
||||
# metadata document is not actually fetched as IndieAuth doesn't
|
||||
# require it.
|
||||
"client_id_metadata_document_supported": True,
|
||||
"response_types_supported": ["code"],
|
||||
"service_documentation": (
|
||||
"https://developers.home-assistant.io/docs/auth_api"
|
||||
|
||||
@@ -122,6 +122,7 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
|
||||
"alarm_control_panel",
|
||||
"assist_satellite",
|
||||
"battery",
|
||||
"calendar",
|
||||
"climate",
|
||||
"cover",
|
||||
"device_tracker",
|
||||
@@ -142,11 +143,14 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
|
||||
"person",
|
||||
"power",
|
||||
"schedule",
|
||||
"select",
|
||||
"siren",
|
||||
"switch",
|
||||
"temperature",
|
||||
"text",
|
||||
"timer",
|
||||
"vacuum",
|
||||
"valve",
|
||||
"water_heater",
|
||||
"window",
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
unit_of_measurement: "%"
|
||||
- domain: sensor
|
||||
device_class: battery
|
||||
- domain: number
|
||||
device_class: battery
|
||||
|
||||
.battery_threshold_number: &battery_threshold_number
|
||||
min: 0
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
.battery_threshold_entity: &battery_threshold_entity
|
||||
- domain: input_number
|
||||
unit_of_measurement: "%"
|
||||
- domain: number
|
||||
device_class: battery
|
||||
- domain: sensor
|
||||
device_class: battery
|
||||
|
||||
|
||||
@@ -578,13 +578,13 @@ class CalendarEntity(Entity):
|
||||
return STATE_OFF
|
||||
|
||||
@callback
|
||||
def async_write_ha_state(self) -> None:
|
||||
def _async_write_ha_state(self) -> None:
|
||||
"""Write the state to the state machine.
|
||||
|
||||
This sets up listeners to handle state transitions for start or end of
|
||||
the current or upcoming event.
|
||||
"""
|
||||
super().async_write_ha_state()
|
||||
super()._async_write_ha_state()
|
||||
if self._alarm_unsubs is None:
|
||||
self._alarm_unsubs = []
|
||||
_LOGGER.debug(
|
||||
|
||||
16
homeassistant/components/calendar/condition.py
Normal file
16
homeassistant/components/calendar/condition.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Provides conditions for calendars."""
|
||||
|
||||
from homeassistant.const import STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.condition import Condition, make_entity_state_condition
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"is_event_active": make_entity_state_condition(DOMAIN, STATE_ON),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
|
||||
"""Return the calendar conditions."""
|
||||
return CONDITIONS
|
||||
14
homeassistant/components/calendar/conditions.yaml
Normal file
14
homeassistant/components/calendar/conditions.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
is_event_active:
|
||||
target:
|
||||
entity:
|
||||
- domain: calendar
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: condition_behavior
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"conditions": {
|
||||
"is_event_active": {
|
||||
"condition": "mdi:calendar-check"
|
||||
}
|
||||
},
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"default": "mdi:calendar",
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_description": "How the state should match on the targeted calendars.",
|
||||
"condition_behavior_name": "Behavior"
|
||||
},
|
||||
"conditions": {
|
||||
"is_event_active": {
|
||||
"description": "Tests if one or more calendars have an active event.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::calendar::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::calendar::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Calendar event is active"
|
||||
}
|
||||
},
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"name": "[%key:component::calendar::title%]",
|
||||
@@ -46,6 +62,12 @@
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"condition_behavior": {
|
||||
"options": {
|
||||
"all": "All",
|
||||
"any": "Any"
|
||||
}
|
||||
},
|
||||
"trigger_offset_type": {
|
||||
"options": {
|
||||
"after": "After",
|
||||
|
||||
@@ -760,12 +760,12 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
return CameraCapabilities(frontend_stream_types)
|
||||
|
||||
@callback
|
||||
def async_write_ha_state(self) -> None:
|
||||
def _async_write_ha_state(self) -> None:
|
||||
"""Write the state to the state machine.
|
||||
|
||||
Schedules async_refresh_providers if support of streams have changed.
|
||||
"""
|
||||
super().async_write_ha_state()
|
||||
super()._async_write_ha_state()
|
||||
if self.__supports_stream != (
|
||||
supports_stream := self.supported_features & CameraEntityFeature.STREAM
|
||||
):
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
"""Provides conditions for climates."""
|
||||
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import ATTR_TEMPERATURE, CONF_OPTIONS, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.condition import (
|
||||
ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL,
|
||||
Condition,
|
||||
ConditionConfig,
|
||||
EntityConditionBase,
|
||||
EntityNumericalConditionWithUnitBase,
|
||||
make_entity_numerical_condition,
|
||||
make_entity_state_condition,
|
||||
@@ -13,6 +21,36 @@ from homeassistant.util.unit_conversion import TemperatureConverter
|
||||
|
||||
from .const import ATTR_HUMIDITY, ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
|
||||
|
||||
CONF_HVAC_MODE = "hvac_mode"
|
||||
|
||||
_HVAC_MODE_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend(
|
||||
{
|
||||
vol.Required(CONF_OPTIONS): {
|
||||
vol.Required(CONF_HVAC_MODE): vol.All(
|
||||
cv.ensure_list, vol.Length(min=1), [vol.Coerce(HVACMode)]
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ClimateHVACModeCondition(EntityConditionBase):
|
||||
"""Condition for climate HVAC mode."""
|
||||
|
||||
_domain_specs = {DOMAIN: DomainSpec()}
|
||||
_schema = _HVAC_MODE_CONDITION_SCHEMA
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
|
||||
"""Initialize the HVAC mode condition."""
|
||||
super().__init__(hass, config)
|
||||
if TYPE_CHECKING:
|
||||
assert config.options is not None
|
||||
self._hvac_modes: set[str] = set(config.options[CONF_HVAC_MODE])
|
||||
|
||||
def is_valid_state(self, entity_state: State) -> bool:
|
||||
"""Check if the state matches any of the expected HVAC modes."""
|
||||
return entity_state.state in self._hvac_modes
|
||||
|
||||
|
||||
class ClimateTargetTemperatureCondition(EntityNumericalConditionWithUnitBase):
|
||||
"""Mixin for climate target temperature conditions with unit conversion."""
|
||||
@@ -28,6 +66,7 @@ class ClimateTargetTemperatureCondition(EntityNumericalConditionWithUnitBase):
|
||||
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"is_hvac_mode": ClimateHVACModeCondition,
|
||||
"is_off": make_entity_state_condition(DOMAIN, HVACMode.OFF),
|
||||
"is_on": make_entity_state_condition(
|
||||
DOMAIN,
|
||||
|
||||
@@ -45,6 +45,21 @@ is_cooling: *condition_common
|
||||
is_drying: *condition_common
|
||||
is_heating: *condition_common
|
||||
|
||||
is_hvac_mode:
|
||||
target: *condition_climate_target
|
||||
fields:
|
||||
behavior: *condition_behavior
|
||||
hvac_mode:
|
||||
context:
|
||||
filter_target: target
|
||||
required: true
|
||||
selector:
|
||||
state:
|
||||
hide_states:
|
||||
- unavailable
|
||||
- unknown
|
||||
multiple: true
|
||||
|
||||
target_humidity:
|
||||
target: *condition_climate_target
|
||||
fields:
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
"is_heating": {
|
||||
"condition": "mdi:fire"
|
||||
},
|
||||
"is_hvac_mode": {
|
||||
"condition": "mdi:thermostat"
|
||||
},
|
||||
"is_off": {
|
||||
"condition": "mdi:power-off"
|
||||
},
|
||||
|
||||
@@ -41,6 +41,20 @@
|
||||
},
|
||||
"name": "Climate-control device is heating"
|
||||
},
|
||||
"is_hvac_mode": {
|
||||
"description": "Tests if one or more climate-control devices are set to a specific HVAC mode.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::climate::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::climate::common::condition_behavior_name%]"
|
||||
},
|
||||
"hvac_mode": {
|
||||
"description": "The HVAC modes to test for.",
|
||||
"name": "Modes"
|
||||
}
|
||||
},
|
||||
"name": "Climate-control device HVAC mode"
|
||||
},
|
||||
"is_off": {
|
||||
"description": "Tests if one or more climate-control devices are off.",
|
||||
"fields": {
|
||||
|
||||
@@ -44,18 +44,18 @@ class DemoRemote(RemoteEntity):
|
||||
return {"last_command_sent": self._last_command_sent}
|
||||
return None
|
||||
|
||||
def turn_on(self, **kwargs: Any) -> None:
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the remote on."""
|
||||
self._attr_is_on = True
|
||||
self.schedule_update_ha_state()
|
||||
self.async_write_ha_state()
|
||||
|
||||
def turn_off(self, **kwargs: Any) -> None:
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the remote off."""
|
||||
self._attr_is_on = False
|
||||
self.schedule_update_ha_state()
|
||||
self.async_write_ha_state()
|
||||
|
||||
def send_command(self, command: Iterable[str], **kwargs: Any) -> None:
|
||||
async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None:
|
||||
"""Send a command to a device."""
|
||||
for com in command:
|
||||
self._last_command_sent = com
|
||||
self.schedule_update_ha_state()
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -61,12 +61,12 @@ class DemoSwitch(SwitchEntity):
|
||||
name=device_name,
|
||||
)
|
||||
|
||||
def turn_on(self, **kwargs: Any) -> None:
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
self._attr_is_on = True
|
||||
self.schedule_update_ha_state()
|
||||
self.async_write_ha_state()
|
||||
|
||||
def turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the device off."""
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch off."""
|
||||
self._attr_is_on = False
|
||||
self.schedule_update_ha_state()
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -51,7 +51,6 @@ def _entity_entry_filter(a: attr.Attribute, _: Any) -> bool:
|
||||
return a.name not in (
|
||||
"_cache",
|
||||
"compat_aliases",
|
||||
"compat_name",
|
||||
"original_name_unprefixed",
|
||||
)
|
||||
|
||||
|
||||
@@ -353,10 +353,10 @@ class DlnaDmrEntity(MediaPlayerEntity):
|
||||
# Device was de/re-connected, state might have changed
|
||||
self.async_write_ha_state()
|
||||
|
||||
def async_write_ha_state(self) -> None:
|
||||
def _async_write_ha_state(self) -> None:
|
||||
"""Write the state."""
|
||||
self._attr_supported_features = self._supported_features()
|
||||
super().async_write_ha_state()
|
||||
super()._async_write_ha_state()
|
||||
|
||||
async def _device_connect(self, location: str) -> None:
|
||||
"""Connect to the device now that it's available."""
|
||||
|
||||
@@ -4,9 +4,12 @@ from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
)
|
||||
|
||||
from . import api
|
||||
from .const import FitbitScope
|
||||
from .const import DOMAIN, FitbitScope
|
||||
from .coordinator import FitbitConfigEntry, FitbitData, FitbitDeviceCoordinator
|
||||
from .exceptions import FitbitApiException, FitbitAuthException
|
||||
from .model import config_from_entry_data
|
||||
@@ -16,11 +19,17 @@ PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: FitbitConfigEntry) -> bool:
|
||||
"""Set up fitbit from a config entry."""
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
try:
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
)
|
||||
)
|
||||
)
|
||||
except ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
fitbit_api = api.OAuthFitbitApi(
|
||||
hass, session, unit_system=entry.data.get("unit_system")
|
||||
|
||||
@@ -121,5 +121,10 @@
|
||||
"name": "Water"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
|
||||
super().__init__(coordinator, ain)
|
||||
|
||||
@callback
|
||||
def async_write_ha_state(self) -> None:
|
||||
def _async_write_ha_state(self) -> None:
|
||||
"""Write the state to the HASS state machine."""
|
||||
if self.data.holiday_active:
|
||||
self._attr_supported_features = ClimateEntityFeature.PRESET_MODE
|
||||
@@ -109,7 +109,7 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
|
||||
self._attr_supported_features = SUPPORTED_FEATURES
|
||||
self._attr_hvac_modes = HVAC_MODES
|
||||
self._attr_preset_modes = PRESET_MODES
|
||||
return super().async_write_ha_state()
|
||||
return super()._async_write_ha_state()
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float:
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"integration_type": "system",
|
||||
"preview_features": { "winter_mode": {} },
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260325.0"]
|
||||
"requirements": ["home-assistant-frontend==20260325.1"]
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
)
|
||||
from homeassistant.helpers.entity import generate_entity_id
|
||||
|
||||
from .api import ApiAuthImpl, get_feature_access
|
||||
@@ -88,11 +91,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleConfigEntry) -> bo
|
||||
_LOGGER.error("Configuration error in %s: %s", YAML_DEVICES, str(err))
|
||||
return False
|
||||
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
try:
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
)
|
||||
)
|
||||
)
|
||||
except ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
# Force a token refresh to fix a bug where tokens were persisted with
|
||||
# expires_in (relative time delta) and expires_at (absolute time) swapped.
|
||||
|
||||
@@ -57,6 +57,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
|
||||
@@ -2,14 +2,19 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import ClientError
|
||||
from gassist_text import TextAssistant
|
||||
from google.oauth2.credentials import Credentials
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryNotReady,
|
||||
OAuth2TokenRequestError,
|
||||
OAuth2TokenRequestReauthError,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv, discovery, intent
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
OAuth2Session,
|
||||
@@ -51,13 +56,11 @@ async def async_setup_entry(
|
||||
session = OAuth2Session(hass, entry, implementation)
|
||||
try:
|
||||
await session.async_ensure_token_valid()
|
||||
except aiohttp.ClientResponseError as err:
|
||||
if 400 <= err.status < 500:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="reauth_required"
|
||||
) from err
|
||||
raise ConfigEntryNotReady from err
|
||||
except aiohttp.ClientError as err:
|
||||
except OAuth2TokenRequestReauthError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="reauth_required"
|
||||
) from err
|
||||
except (OAuth2TokenRequestError, ClientError) as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
mem_storage = InMemoryStorage(hass)
|
||||
|
||||
@@ -8,7 +8,6 @@ import logging
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
from gassist_text import TextAssistant
|
||||
from google.oauth2.credentials import Credentials
|
||||
@@ -26,7 +25,11 @@ from homeassistant.components.media_player import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_ENTITY_ID, CONF_ACCESS_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.exceptions import (
|
||||
HomeAssistantError,
|
||||
OAuth2TokenRequestReauthError,
|
||||
ServiceValidationError,
|
||||
)
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
|
||||
@@ -79,9 +82,8 @@ async def async_send_text_commands(
|
||||
session = entry.runtime_data.session
|
||||
try:
|
||||
await session.async_ensure_token_valid()
|
||||
except aiohttp.ClientResponseError as err:
|
||||
if 400 <= err.status < 500:
|
||||
entry.async_start_reauth(hass)
|
||||
except OAuth2TokenRequestReauthError:
|
||||
entry.async_start_reauth(hass)
|
||||
raise
|
||||
|
||||
credentials = Credentials(session.token[CONF_ACCESS_TOKEN]) # type: ignore[no-untyped-call]
|
||||
|
||||
@@ -33,11 +33,18 @@ async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: GooglePhotosConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Google Photos from a config entry."""
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
try:
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
)
|
||||
)
|
||||
)
|
||||
except config_entry_oauth2_flow.ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
|
||||
web_session = async_get_clientsession(hass)
|
||||
oauth_session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
auth = api.AsyncConfigEntryAuth(web_session, oauth_session)
|
||||
|
||||
@@ -68,6 +68,9 @@
|
||||
"no_access_to_path": {
|
||||
"message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
|
||||
},
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
},
|
||||
"upload_error": {
|
||||
"message": "Failed to upload content: {message}"
|
||||
}
|
||||
|
||||
@@ -1,15 +1,73 @@
|
||||
"""Provides conditions for humidifiers."""
|
||||
|
||||
from homeassistant.const import PERCENTAGE, STATE_OFF, STATE_ON
|
||||
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
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.condition import (
|
||||
ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL,
|
||||
Condition,
|
||||
ConditionConfig,
|
||||
EntityStateConditionBase,
|
||||
make_entity_numerical_condition,
|
||||
make_entity_state_condition,
|
||||
)
|
||||
from homeassistant.helpers.entity import get_supported_features
|
||||
|
||||
from .const import (
|
||||
ATTR_ACTION,
|
||||
ATTR_HUMIDITY,
|
||||
DOMAIN,
|
||||
HumidifierAction,
|
||||
HumidifierEntityFeature,
|
||||
)
|
||||
|
||||
CONF_MODE = "mode"
|
||||
|
||||
IS_MODE_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend(
|
||||
{
|
||||
vol.Required(CONF_OPTIONS): {
|
||||
vol.Required(CONF_MODE): vol.All(cv.ensure_list, vol.Length(min=1), [str]),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> bool:
|
||||
"""Test if an entity supports the specified features."""
|
||||
try:
|
||||
return bool(get_supported_features(hass, entity_id) & features)
|
||||
except HomeAssistantError:
|
||||
return False
|
||||
|
||||
|
||||
class IsModeCondition(EntityStateConditionBase):
|
||||
"""Condition for humidifier mode."""
|
||||
|
||||
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_MODE)}
|
||||
_schema = IS_MODE_CONDITION_SCHEMA
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
|
||||
"""Initialize the mode condition."""
|
||||
super().__init__(hass, config)
|
||||
if TYPE_CHECKING:
|
||||
assert config.options is not None
|
||||
self._states = set(config.options[CONF_MODE])
|
||||
|
||||
def entity_filter(self, entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
entities = super().entity_filter(entities)
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if _supports_feature(self._hass, entity_id, HumidifierEntityFeature.MODES)
|
||||
}
|
||||
|
||||
from .const import ATTR_ACTION, ATTR_HUMIDITY, DOMAIN, HumidifierAction
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"is_off": make_entity_state_condition(DOMAIN, STATE_OFF),
|
||||
@@ -20,6 +78,7 @@ CONDITIONS: dict[str, type[Condition]] = {
|
||||
"is_humidifying": make_entity_state_condition(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.HUMIDIFYING
|
||||
),
|
||||
"is_mode": IsModeCondition,
|
||||
"is_target_humidity": make_entity_numerical_condition(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
|
||||
valid_unit=PERCENTAGE,
|
||||
|
||||
@@ -32,6 +32,19 @@ is_on: *condition_common
|
||||
is_drying: *condition_common
|
||||
is_humidifying: *condition_common
|
||||
|
||||
is_mode:
|
||||
target: *condition_humidifier_target
|
||||
fields:
|
||||
behavior: *condition_behavior
|
||||
mode:
|
||||
context:
|
||||
filter_target: target
|
||||
required: true
|
||||
selector:
|
||||
state:
|
||||
attribute: available_modes
|
||||
multiple: true
|
||||
|
||||
is_target_humidity:
|
||||
target: *condition_humidifier_target
|
||||
fields:
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
"is_humidifying": {
|
||||
"condition": "mdi:arrow-up-bold"
|
||||
},
|
||||
"is_mode": {
|
||||
"condition": "mdi:air-humidifier"
|
||||
},
|
||||
"is_off": {
|
||||
"condition": "mdi:air-humidifier-off"
|
||||
},
|
||||
|
||||
@@ -28,6 +28,20 @@
|
||||
},
|
||||
"name": "Humidifier is humidifying"
|
||||
},
|
||||
"is_mode": {
|
||||
"description": "Tests if one or more humidifiers are set to a specific mode.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::humidifier::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::humidifier::common::condition_behavior_name%]"
|
||||
},
|
||||
"mode": {
|
||||
"description": "The operation modes to check for.",
|
||||
"name": "Mode"
|
||||
}
|
||||
},
|
||||
"name": "Humidifier is in mode"
|
||||
},
|
||||
"is_off": {
|
||||
"description": "Tests if one or more humidifiers are off.",
|
||||
"fields": {
|
||||
|
||||
@@ -10,8 +10,11 @@ from homeassistant.components.humidifier import (
|
||||
ATTR_CURRENT_HUMIDITY as HUMIDIFIER_ATTR_CURRENT_HUMIDITY,
|
||||
DOMAIN as HUMIDIFIER_DOMAIN,
|
||||
)
|
||||
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
|
||||
from homeassistant.components.weather import (
|
||||
ATTR_WEATHER_HUMIDITY,
|
||||
DOMAIN as WEATHER_DOMAIN,
|
||||
)
|
||||
from homeassistant.const import PERCENTAGE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
@@ -25,7 +28,9 @@ HUMIDITY_DOMAIN_SPECS = {
|
||||
value_source=HUMIDIFIER_ATTR_CURRENT_HUMIDITY,
|
||||
),
|
||||
SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.HUMIDITY),
|
||||
NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.HUMIDITY),
|
||||
WEATHER_DOMAIN: DomainSpec(
|
||||
value_source=ATTR_WEATHER_HUMIDITY,
|
||||
),
|
||||
}
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
|
||||
@@ -17,10 +17,9 @@ is_value:
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: humidity
|
||||
- domain: number
|
||||
device_class: humidity
|
||||
- domain: climate
|
||||
- domain: humidifier
|
||||
- domain: weather
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["idasen-ha==2.6.4"]
|
||||
"requirements": ["idasen-ha==2.6.5"]
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ from homeassistant.components.binary_sensor import (
|
||||
DOMAIN as BINARY_SENSOR_DOMAIN,
|
||||
BinarySensorDeviceClass,
|
||||
)
|
||||
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
|
||||
from homeassistant.const import LIGHT_LUX, STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -22,7 +21,6 @@ ILLUMINANCE_DETECTED_DOMAIN_SPECS = {
|
||||
}
|
||||
ILLUMINANCE_VALUE_DOMAIN_SPECS = {
|
||||
SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.ILLUMINANCE),
|
||||
NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.ILLUMINANCE),
|
||||
}
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
|
||||
@@ -23,8 +23,6 @@ is_value:
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: illuminance
|
||||
- domain: number
|
||||
device_class: illuminance
|
||||
fields:
|
||||
behavior: *condition_behavior
|
||||
threshold:
|
||||
|
||||
@@ -6,7 +6,6 @@ from homeassistant.components.binary_sensor import (
|
||||
DOMAIN as BINARY_SENSOR_DOMAIN,
|
||||
BinarySensorDeviceClass,
|
||||
)
|
||||
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
|
||||
from homeassistant.const import LIGHT_LUX, STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -19,7 +18,6 @@ from homeassistant.helpers.trigger import (
|
||||
)
|
||||
|
||||
ILLUMINANCE_DOMAIN_SPECS: dict[str, DomainSpec] = {
|
||||
NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.ILLUMINANCE),
|
||||
SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.ILLUMINANCE),
|
||||
}
|
||||
|
||||
|
||||
@@ -29,8 +29,6 @@
|
||||
|
||||
.trigger_numerical_target: &trigger_numerical_target
|
||||
entity:
|
||||
- domain: number
|
||||
device_class: illuminance
|
||||
- domain: sensor
|
||||
device_class: illuminance
|
||||
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["thinqconnect"],
|
||||
"requirements": ["thinqconnect==1.0.9"]
|
||||
"requirements": ["thinqconnect==1.0.11"]
|
||||
}
|
||||
|
||||
@@ -1,12 +1,43 @@
|
||||
"""Provides conditions for lights."""
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.condition import Condition, make_entity_state_condition
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.condition import (
|
||||
Condition,
|
||||
EntityNumericalConditionBase,
|
||||
make_entity_state_condition,
|
||||
)
|
||||
|
||||
from . import ATTR_BRIGHTNESS
|
||||
from .const import DOMAIN
|
||||
|
||||
BRIGHTNESS_DOMAIN_SPECS = {
|
||||
DOMAIN: DomainSpec(value_source=ATTR_BRIGHTNESS),
|
||||
}
|
||||
|
||||
|
||||
class BrightnessCondition(EntityNumericalConditionBase):
|
||||
"""Condition for light brightness with uint8 to percentage conversion."""
|
||||
|
||||
_domain_specs = BRIGHTNESS_DOMAIN_SPECS
|
||||
_valid_unit = "%"
|
||||
|
||||
def _get_tracked_value(self, entity_state: State) -> Any:
|
||||
"""Get the brightness value converted from uint8 (0-255) to percentage (0-100)."""
|
||||
raw = super()._get_tracked_value(entity_state)
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return (float(raw) / 255.0) * 100.0
|
||||
except TypeError, ValueError:
|
||||
return None
|
||||
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"is_brightness": BrightnessCondition,
|
||||
"is_off": make_entity_state_condition(DOMAIN, STATE_OFF),
|
||||
"is_on": make_entity_state_condition(DOMAIN, STATE_ON),
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
.condition_common: &condition_common
|
||||
target:
|
||||
target: &condition_light_target
|
||||
entity:
|
||||
domain: light
|
||||
fields:
|
||||
behavior:
|
||||
behavior: &condition_behavior
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
@@ -13,5 +13,31 @@
|
||||
- all
|
||||
- any
|
||||
|
||||
.brightness_threshold_entity: &brightness_threshold_entity
|
||||
- domain: input_number
|
||||
unit_of_measurement: "%"
|
||||
- domain: number
|
||||
unit_of_measurement: "%"
|
||||
- domain: sensor
|
||||
unit_of_measurement: "%"
|
||||
|
||||
.brightness_threshold_number: &brightness_threshold_number
|
||||
min: 0
|
||||
max: 100
|
||||
mode: box
|
||||
unit_of_measurement: "%"
|
||||
|
||||
is_off: *condition_common
|
||||
is_on: *condition_common
|
||||
|
||||
is_brightness:
|
||||
target: *condition_light_target
|
||||
fields:
|
||||
behavior: *condition_behavior
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *brightness_threshold_entity
|
||||
mode: is
|
||||
number: *brightness_threshold_number
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"conditions": {
|
||||
"is_brightness": {
|
||||
"condition": "mdi:lightbulb-on-50"
|
||||
},
|
||||
"is_off": {
|
||||
"condition": "mdi:lightbulb-off"
|
||||
},
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
"common": {
|
||||
"condition_behavior_description": "How the state should match on the targeted lights.",
|
||||
"condition_behavior_name": "Behavior",
|
||||
"condition_threshold_description": "What to test for and threshold values.",
|
||||
"condition_threshold_name": "Threshold configuration",
|
||||
"field_brightness_description": "Number indicating brightness, where 0 turns the light off, 1 is the minimum brightness, and 255 is the maximum brightness.",
|
||||
"field_brightness_name": "Brightness value",
|
||||
"field_brightness_pct_description": "Number indicating the percentage of full brightness, where 0 turns the light off, 1 is the minimum brightness, and 100 is the maximum brightness.",
|
||||
@@ -42,6 +44,20 @@
|
||||
"trigger_threshold_name": "Threshold configuration"
|
||||
},
|
||||
"conditions": {
|
||||
"is_brightness": {
|
||||
"description": "Tests the brightness of one or more lights.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::light::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::light::common::condition_behavior_name%]"
|
||||
},
|
||||
"threshold": {
|
||||
"description": "[%key:component::light::common::condition_threshold_description%]",
|
||||
"name": "[%key:component::light::common::condition_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Light brightness"
|
||||
},
|
||||
"is_off": {
|
||||
"description": "Tests if one or more lights are off.",
|
||||
"fields": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Constants for the Matter integration."""
|
||||
|
||||
import logging
|
||||
from typing import Final
|
||||
|
||||
from chip.clusters import Objects as clusters
|
||||
|
||||
@@ -114,3 +115,5 @@ SERVICE_CREDENTIAL_TYPES = [
|
||||
CRED_TYPE_FINGER_VEIN,
|
||||
CRED_TYPE_FACE,
|
||||
]
|
||||
|
||||
CONCENTRATION_BECQUERELS_PER_CUBIC_METER: Final = "Bq/m³"
|
||||
|
||||
@@ -140,6 +140,9 @@
|
||||
"pump_status": {
|
||||
"default": "mdi:pump"
|
||||
},
|
||||
"radon_concentration": {
|
||||
"default": "mdi:radioactive"
|
||||
},
|
||||
"tank_percentage": {
|
||||
"default": "mdi:water-boiler"
|
||||
},
|
||||
|
||||
@@ -399,6 +399,47 @@ DISCOVERY_SCHEMAS = [
|
||||
),
|
||||
entity_class=MatterNumber,
|
||||
required_attributes=(clusters.OccupancySensing.Attributes.HoldTime,),
|
||||
# HoldTime is shared by PIR-specific numbers as a required attribute.
|
||||
# Keep discovery open so this generic schema does not block them.
|
||||
allow_multi=True,
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.NUMBER,
|
||||
entity_description=MatterNumberEntityDescription(
|
||||
key="OccupancySensingPIRUnoccupiedToOccupiedDelay",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
translation_key="detection_delay",
|
||||
native_max_value=65534,
|
||||
native_min_value=0,
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
mode=NumberMode.BOX,
|
||||
),
|
||||
entity_class=MatterNumber,
|
||||
required_attributes=(
|
||||
clusters.OccupancySensing.Attributes.PIRUnoccupiedToOccupiedDelay,
|
||||
# This attribute is mandatory when the PIRUnoccupiedToOccupiedDelay is present
|
||||
clusters.OccupancySensing.Attributes.HoldTime,
|
||||
),
|
||||
featuremap_contains=clusters.OccupancySensing.Bitmaps.Feature.kPassiveInfrared,
|
||||
allow_multi=True,
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.NUMBER,
|
||||
entity_description=MatterNumberEntityDescription(
|
||||
key="OccupancySensingPIRUnoccupiedToOccupiedThreshold",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
translation_key="detection_threshold",
|
||||
native_max_value=254,
|
||||
native_min_value=1,
|
||||
mode=NumberMode.BOX,
|
||||
),
|
||||
entity_class=MatterNumber,
|
||||
required_attributes=(
|
||||
clusters.OccupancySensing.Attributes.PIRUnoccupiedToOccupiedThreshold,
|
||||
clusters.OccupancySensing.Attributes.HoldTime,
|
||||
),
|
||||
featuremap_contains=clusters.OccupancySensing.Bitmaps.Feature.kPassiveInfrared,
|
||||
allow_multi=True,
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.NUMBER,
|
||||
|
||||
@@ -48,6 +48,7 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util, slugify
|
||||
|
||||
from .const import CONCENTRATION_BECQUERELS_PER_CUBIC_METER
|
||||
from .entity import MatterEntity, MatterEntityDescription
|
||||
from .helpers import get_matter
|
||||
from .models import MatterDiscoverySchema
|
||||
@@ -744,6 +745,19 @@ DISCOVERY_SCHEMAS = [
|
||||
clusters.OzoneConcentrationMeasurement.Attributes.MeasuredValue,
|
||||
),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SENSOR,
|
||||
entity_description=MatterSensorEntityDescription(
|
||||
key="RadonSensor",
|
||||
native_unit_of_measurement=CONCENTRATION_BECQUERELS_PER_CUBIC_METER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="radon_concentration",
|
||||
),
|
||||
entity_class=MatterSensor,
|
||||
required_attributes=(
|
||||
clusters.RadonConcentrationMeasurement.Attributes.MeasuredValue,
|
||||
),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SENSOR,
|
||||
entity_description=MatterSensorEntityDescription(
|
||||
|
||||
@@ -214,6 +214,12 @@
|
||||
"cook_time": {
|
||||
"name": "Cooking time"
|
||||
},
|
||||
"detection_delay": {
|
||||
"name": "Detection delay"
|
||||
},
|
||||
"detection_threshold": {
|
||||
"name": "Detection threshold"
|
||||
},
|
||||
"hold_time": {
|
||||
"name": "Hold time"
|
||||
},
|
||||
@@ -549,6 +555,9 @@
|
||||
"pump_speed": {
|
||||
"name": "Rotation speed"
|
||||
},
|
||||
"radon_concentration": {
|
||||
"name": "Radon concentration"
|
||||
},
|
||||
"reactive_current": {
|
||||
"name": "Reactive current"
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import cast
|
||||
|
||||
from homeassistant.components.application_credentials import AuthorizationServer
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_entry_oauth2_flow, llm
|
||||
|
||||
from .application_credentials import authorization_server_context
|
||||
@@ -42,7 +43,14 @@ async def _create_token_manager(
|
||||
hass: HomeAssistant, entry: ModelContextProtocolConfigEntry
|
||||
) -> TokenManager | None:
|
||||
"""Create a OAuth token manager for the config entry if the server requires authentication."""
|
||||
if not (implementation := await async_get_config_entry_implementation(hass, entry)):
|
||||
try:
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
except config_entry_oauth2_flow.ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
if not implementation:
|
||||
return None
|
||||
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
|
||||
@@ -56,5 +56,10 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,6 +270,7 @@ class ProgramPhaseOven(MieleEnum, missing_to_none=True):
|
||||
process_finished = 3078
|
||||
searing = 3080
|
||||
roasting = 3081
|
||||
cooling_down = 3083
|
||||
energy_save = 3084
|
||||
pre_heating = 3099
|
||||
|
||||
@@ -452,6 +453,7 @@ class WashingMachineProgramId(MieleEnum, missing_to_none=True):
|
||||
proofing = 27, 10057
|
||||
sportswear = 29, 10052
|
||||
automatic_plus = 31
|
||||
table_linen = 33
|
||||
outerwear = 37
|
||||
pillows = 39
|
||||
cool_air = 45 # washer-dryer
|
||||
@@ -586,6 +588,7 @@ class OvenProgramId(MieleEnum, missing_to_none=True):
|
||||
microwave_fan_grill = 23
|
||||
conventional_heat = 24
|
||||
top_heat = 25
|
||||
booster = 27
|
||||
fan_grill = 29
|
||||
bottom_heat = 31
|
||||
moisture_plus_auto_roast = 35, 48
|
||||
@@ -594,6 +597,7 @@ class OvenProgramId(MieleEnum, missing_to_none=True):
|
||||
moisture_plus_conventional_heat = 51, 76
|
||||
popcorn = 53
|
||||
quick_microwave = 54
|
||||
airfry = 95
|
||||
custom_program_1 = 97
|
||||
custom_program_2 = 98
|
||||
custom_program_3 = 99
|
||||
|
||||
@@ -273,6 +273,7 @@
|
||||
"program_id": {
|
||||
"name": "Program",
|
||||
"state": {
|
||||
"airfry": "AirFry",
|
||||
"almond_macaroons_1_tray": "Almond macaroons (1 tray)",
|
||||
"almond_macaroons_2_trays": "Almond macaroons (2 trays)",
|
||||
"amaranth": "Amaranth",
|
||||
@@ -334,6 +335,7 @@
|
||||
"blanching": "Blanching",
|
||||
"blueberry_muffins": "Blueberry muffins",
|
||||
"bologna_sausage": "Bologna sausage",
|
||||
"booster": "Booster",
|
||||
"bottling": "Bottling",
|
||||
"bottling_hard": "Bottling (hard)",
|
||||
"bottling_medium": "Bottling (medium)",
|
||||
@@ -881,6 +883,7 @@
|
||||
"swiss_roll": "Swiss roll",
|
||||
"swiss_toffee_cream_100_ml": "Swiss toffee cream (100 ml)",
|
||||
"swiss_toffee_cream_150_ml": "Swiss toffee cream (150 ml)",
|
||||
"table_linen": "Table linen",
|
||||
"tagliatelli_fresh": "Tagliatelli (fresh)",
|
||||
"tall_items": "Tall items",
|
||||
"tart_flambe": "Tart flambè",
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
unit_of_measurement: "%"
|
||||
- domain: sensor
|
||||
device_class: moisture
|
||||
- domain: number
|
||||
device_class: moisture
|
||||
|
||||
.moisture_threshold_number: &moisture_threshold_number
|
||||
min: 0
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
.moisture_threshold_entity: &moisture_threshold_entity
|
||||
- domain: input_number
|
||||
unit_of_measurement: "%"
|
||||
- domain: number
|
||||
device_class: moisture
|
||||
- domain: sensor
|
||||
device_class: moisture
|
||||
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
import secrets
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import ClientError
|
||||
import pyatmo
|
||||
|
||||
from homeassistant.components import cloud
|
||||
@@ -19,7 +18,12 @@ from homeassistant.components.webhook import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryNotReady,
|
||||
OAuth2TokenRequestError,
|
||||
OAuth2TokenRequestReauthError,
|
||||
)
|
||||
from homeassistant.helpers import aiohttp_client, config_validation as cv
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
@@ -89,14 +93,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
session = OAuth2Session(hass, entry, implementation)
|
||||
try:
|
||||
await session.async_ensure_token_valid()
|
||||
except aiohttp.ClientResponseError as ex:
|
||||
_LOGGER.warning("API error: %s (%s)", ex.status, ex.message)
|
||||
if ex.status in (
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
HTTPStatus.UNAUTHORIZED,
|
||||
HTTPStatus.FORBIDDEN,
|
||||
):
|
||||
raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from ex
|
||||
except OAuth2TokenRequestReauthError as ex:
|
||||
raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from ex
|
||||
except (OAuth2TokenRequestError, ClientError) as ex:
|
||||
raise ConfigEntryNotReady from ex
|
||||
|
||||
required_scopes = api.get_api_scopes(entry.data["auth_implementation"])
|
||||
|
||||
@@ -8,11 +8,8 @@ from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import (
|
||||
ATTR_AFFECTED_AREAS,
|
||||
@@ -28,13 +25,13 @@ from .const import (
|
||||
ATTR_WEB,
|
||||
CONF_MESSAGE_SLOTS,
|
||||
CONF_REGIONS,
|
||||
DOMAIN,
|
||||
)
|
||||
from .coordinator import NinaConfigEntry, NINADataUpdateCoordinator
|
||||
from .entity import NinaEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
_: HomeAssistant,
|
||||
config_entry: NinaConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
@@ -46,7 +43,7 @@ async def async_setup_entry(
|
||||
message_slots: int = config_entry.data[CONF_MESSAGE_SLOTS]
|
||||
|
||||
async_add_entities(
|
||||
NINAMessage(coordinator, ent, regions[ent], i + 1, config_entry)
|
||||
NINAMessage(coordinator, ent, regions[ent], i + 1)
|
||||
for ent in coordinator.data
|
||||
for i in range(message_slots)
|
||||
)
|
||||
@@ -55,7 +52,7 @@ async def async_setup_entry(
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
class NINAMessage(CoordinatorEntity[NINADataUpdateCoordinator], BinarySensorEntity):
|
||||
class NINAMessage(NinaEntity, BinarySensorEntity):
|
||||
"""Representation of an NINA warning."""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.SAFETY
|
||||
@@ -67,31 +64,20 @@ class NINAMessage(CoordinatorEntity[NINADataUpdateCoordinator], BinarySensorEnti
|
||||
region: str,
|
||||
region_name: str,
|
||||
slot_id: int,
|
||||
config_entry: ConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(coordinator)
|
||||
super().__init__(coordinator, region, region_name, slot_id)
|
||||
|
||||
self._region = region
|
||||
self._warning_index = slot_id - 1
|
||||
|
||||
self._attr_name = f"Warning: {region_name} {slot_id}"
|
||||
self._attr_translation_key = "warning"
|
||||
self._attr_unique_id = f"{region}-{slot_id}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, config_entry.entry_id)},
|
||||
manufacturer="NINA",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return the state of the sensor."""
|
||||
if len(self.coordinator.data[self._region]) <= self._warning_index:
|
||||
if self._get_active_warnings_count() <= self._warning_index:
|
||||
return False
|
||||
|
||||
data = self.coordinator.data[self._region][self._warning_index]
|
||||
|
||||
return data.is_valid
|
||||
return self._get_warning_data().is_valid
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
@@ -99,7 +85,7 @@ class NINAMessage(CoordinatorEntity[NINADataUpdateCoordinator], BinarySensorEnti
|
||||
if not self.is_on:
|
||||
return {}
|
||||
|
||||
data = self.coordinator.data[self._region][self._warning_index]
|
||||
data = self._get_warning_data()
|
||||
|
||||
return {
|
||||
ATTR_HEADLINE: data.headline,
|
||||
|
||||
@@ -12,6 +12,7 @@ from pynina import ApiError, Nina
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import (
|
||||
@@ -64,6 +65,12 @@ class NINADataUpdateCoordinator(
|
||||
]
|
||||
self.area_filter: str = config_entry.data[CONF_FILTERS][CONF_AREA_FILTER]
|
||||
|
||||
self.device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, config_entry.entry_id)},
|
||||
manufacturer="NINA",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
regions: dict[str, str] = config_entry.data[CONF_REGIONS]
|
||||
for region in regions:
|
||||
self._nina.add_region(region)
|
||||
|
||||
36
homeassistant/components/nina/entity.py
Normal file
36
homeassistant/components/nina/entity.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""NINA common entity."""
|
||||
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .coordinator import NINADataUpdateCoordinator, NinaWarningData
|
||||
|
||||
|
||||
class NinaEntity(CoordinatorEntity[NINADataUpdateCoordinator]):
|
||||
"""Base class for NINA entities."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NINADataUpdateCoordinator,
|
||||
region: str,
|
||||
region_name: str,
|
||||
slot_id: int,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._region = region
|
||||
self._warning_index = slot_id - 1
|
||||
|
||||
self._attr_translation_placeholders = {
|
||||
"region_name": region_name,
|
||||
"slot_id": str(slot_id),
|
||||
}
|
||||
self._attr_device_info = coordinator.device_info
|
||||
|
||||
def _get_active_warnings_count(self) -> int:
|
||||
"""Return the number of active warnings for the region."""
|
||||
return len(self.coordinator.data[self._region])
|
||||
|
||||
def _get_warning_data(self) -> NinaWarningData:
|
||||
"""Return warning data."""
|
||||
return self.coordinator.data[self._region][self._warning_index]
|
||||
@@ -45,6 +45,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"warning": {
|
||||
"name": "Warning: {region_name} {slot_id}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"abort": {
|
||||
"no_fetch": "[%key:component::nina::config::abort::no_fetch%]",
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
|
||||
from homeassistant.const import UnitOfPower
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -14,7 +13,6 @@ from homeassistant.helpers.condition import (
|
||||
from homeassistant.util.unit_conversion import PowerConverter
|
||||
|
||||
POWER_DOMAIN_SPECS = {
|
||||
NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.POWER),
|
||||
SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.POWER),
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,6 @@
|
||||
is_value:
|
||||
target:
|
||||
entity:
|
||||
- domain: number
|
||||
device_class: power
|
||||
- domain: sensor
|
||||
device_class: power
|
||||
fields:
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
|
||||
from homeassistant.const import UnitOfPower
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -15,7 +14,6 @@ from homeassistant.helpers.trigger import (
|
||||
from homeassistant.util.unit_conversion import PowerConverter
|
||||
|
||||
POWER_DOMAIN_SPECS: dict[str, DomainSpec] = {
|
||||
NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.POWER),
|
||||
SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.POWER),
|
||||
}
|
||||
|
||||
|
||||
@@ -29,8 +29,6 @@
|
||||
|
||||
.trigger_target: &trigger_target
|
||||
entity:
|
||||
- domain: number
|
||||
device_class: power
|
||||
- domain: sensor
|
||||
device_class: power
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import DOMAIN, ProxmoxPermission
|
||||
from .coordinator import ProxmoxConfigEntry, ProxmoxCoordinator, ProxmoxNodeData
|
||||
from .entity import ProxmoxContainerEntity, ProxmoxNodeEntity, ProxmoxVMEntity
|
||||
from .helpers import is_granted
|
||||
@@ -34,6 +34,8 @@ class ProxmoxNodeButtonNodeEntityDescription(ButtonEntityDescription):
|
||||
"""Class to hold Proxmox node button description."""
|
||||
|
||||
press_action: Callable[[ProxmoxCoordinator, str], None]
|
||||
permission: ProxmoxPermission = ProxmoxPermission.POWER
|
||||
permission_raise: str = "no_permission_node_power"
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
@@ -41,6 +43,8 @@ class ProxmoxVMButtonEntityDescription(ButtonEntityDescription):
|
||||
"""Class to hold Proxmox VM button description."""
|
||||
|
||||
press_action: Callable[[ProxmoxCoordinator, str, int], None]
|
||||
permission: ProxmoxPermission = ProxmoxPermission.POWER
|
||||
permission_raise: str = "no_permission_vm_lxc_power"
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
@@ -48,6 +52,8 @@ class ProxmoxContainerButtonEntityDescription(ButtonEntityDescription):
|
||||
"""Class to hold Proxmox container button description."""
|
||||
|
||||
press_action: Callable[[ProxmoxCoordinator, str, int], None]
|
||||
permission: ProxmoxPermission = ProxmoxPermission.POWER
|
||||
permission_raise: str = "no_permission_vm_lxc_power"
|
||||
|
||||
|
||||
NODE_BUTTONS: tuple[ProxmoxNodeButtonNodeEntityDescription, ...] = (
|
||||
@@ -156,6 +162,8 @@ VM_BUTTONS: tuple[ProxmoxVMButtonEntityDescription, ...] = (
|
||||
)
|
||||
)
|
||||
),
|
||||
permission=ProxmoxPermission.SNAPSHOT,
|
||||
permission_raise="no_permission_snapshot",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
)
|
||||
@@ -199,6 +207,8 @@ CONTAINER_BUTTONS: tuple[ProxmoxContainerButtonEntityDescription, ...] = (
|
||||
)
|
||||
)
|
||||
),
|
||||
permission=ProxmoxPermission.SNAPSHOT,
|
||||
permission_raise="no_permission_snapshot",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
)
|
||||
@@ -315,10 +325,15 @@ class ProxmoxNodeButtonEntity(ProxmoxNodeEntity, ProxmoxBaseButton):
|
||||
async def _async_press_call(self) -> None:
|
||||
"""Execute the node button action via executor."""
|
||||
node_id = self._node_data.node["node"]
|
||||
if not is_granted(self.coordinator.permissions, p_type="nodes", p_id=node_id):
|
||||
if not is_granted(
|
||||
self.coordinator.permissions,
|
||||
p_type="nodes",
|
||||
p_id=node_id,
|
||||
permission=self.entity_description.permission,
|
||||
):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_permission_node_power",
|
||||
translation_key=self.entity_description.permission_raise,
|
||||
)
|
||||
await self.hass.async_add_executor_job(
|
||||
self.entity_description.press_action,
|
||||
@@ -335,10 +350,15 @@ class ProxmoxVMButtonEntity(ProxmoxVMEntity, ProxmoxBaseButton):
|
||||
async def _async_press_call(self) -> None:
|
||||
"""Execute the VM button action via executor."""
|
||||
vmid = self.vm_data["vmid"]
|
||||
if not is_granted(self.coordinator.permissions, p_type="vms", p_id=vmid):
|
||||
if not is_granted(
|
||||
self.coordinator.permissions,
|
||||
p_type="vms",
|
||||
p_id=vmid,
|
||||
permission=self.entity_description.permission,
|
||||
):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_permission_vm_lxc_power",
|
||||
translation_key=self.entity_description.permission_raise,
|
||||
)
|
||||
await self.hass.async_add_executor_job(
|
||||
self.entity_description.press_action,
|
||||
@@ -357,10 +377,15 @@ class ProxmoxContainerButtonEntity(ProxmoxContainerEntity, ProxmoxBaseButton):
|
||||
"""Execute the container button action via executor."""
|
||||
vmid = self.container_data["vmid"]
|
||||
# Container power actions fall under vms
|
||||
if not is_granted(self.coordinator.permissions, p_type="vms", p_id=vmid):
|
||||
if not is_granted(
|
||||
self.coordinator.permissions,
|
||||
p_type="vms",
|
||||
p_id=vmid,
|
||||
permission=self.entity_description.permission,
|
||||
):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_permission_vm_lxc_power",
|
||||
translation_key=self.entity_description.permission_raise,
|
||||
)
|
||||
await self.hass.async_add_executor_job(
|
||||
self.entity_description.press_action,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Constants for ProxmoxVE."""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
DOMAIN = "proxmoxve"
|
||||
CONF_AUTH_METHOD = "auth_method"
|
||||
CONF_REALM = "realm"
|
||||
@@ -33,4 +35,9 @@ TYPE_VM = 0
|
||||
TYPE_CONTAINER = 1
|
||||
UPDATE_INTERVAL = 60
|
||||
|
||||
PERM_POWER = "VM.PowerMgmt"
|
||||
|
||||
class ProxmoxPermission(StrEnum):
|
||||
"""Proxmox permissions."""
|
||||
|
||||
POWER = "VM.PowerMgmt"
|
||||
SNAPSHOT = "VM.Snapshot"
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""Helpers for Proxmox VE."""
|
||||
|
||||
from .const import PERM_POWER
|
||||
from .const import ProxmoxPermission
|
||||
|
||||
|
||||
def is_granted(
|
||||
permissions: dict[str, dict[str, int]],
|
||||
p_type: str = "vms",
|
||||
p_id: str | int | None = None, # can be str for nodes
|
||||
permission: str = PERM_POWER,
|
||||
permission: ProxmoxPermission = ProxmoxPermission.POWER,
|
||||
) -> bool:
|
||||
"""Validate user permissions for the given type and permission."""
|
||||
paths = [f"/{p_type}/{p_id}", f"/{p_type}", "/"]
|
||||
|
||||
@@ -315,6 +315,9 @@
|
||||
"no_permission_node_power": {
|
||||
"message": "The configured Proxmox VE user does not have permission to manage the power state of nodes. Please grant the user the 'VM.PowerMgmt' permission and try again."
|
||||
},
|
||||
"no_permission_snapshot": {
|
||||
"message": "The configured Proxmox VE user does not have permission to create snapshots of VMs and containers. Please grant the user the 'VM.Snapshot' permission and try again."
|
||||
},
|
||||
"no_permission_vm_lxc_power": {
|
||||
"message": "The configured Proxmox VE user does not have permission to manage the power state of VMs and containers. Please grant the user the 'VM.PowerMgmt' permission and try again."
|
||||
},
|
||||
|
||||
@@ -56,7 +56,7 @@ class RadarrCalendarEntity(RadarrEntity, CalendarEntity):
|
||||
return await self.coordinator.async_get_events(start_date, end_date)
|
||||
|
||||
@callback
|
||||
def async_write_ha_state(self) -> None:
|
||||
def _async_write_ha_state(self) -> None:
|
||||
"""Write the state to the state machine."""
|
||||
if self.coordinator.event:
|
||||
self._attr_extra_state_attributes = {
|
||||
@@ -64,4 +64,4 @@ class RadarrCalendarEntity(RadarrEntity, CalendarEntity):
|
||||
}
|
||||
else:
|
||||
self._attr_extra_state_attributes = {}
|
||||
super().async_write_ha_state()
|
||||
super()._async_write_ha_state()
|
||||
|
||||
55
homeassistant/components/select/condition.py
Normal file
55
homeassistant/components/select/condition.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Provides conditions for selects."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.input_select import DOMAIN as INPUT_SELECT_DOMAIN
|
||||
from homeassistant.const import CONF_OPTIONS
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.condition import (
|
||||
ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL,
|
||||
Condition,
|
||||
ConditionConfig,
|
||||
EntityStateConditionBase,
|
||||
)
|
||||
|
||||
from .const import CONF_OPTION, DOMAIN
|
||||
|
||||
IS_OPTION_SELECTED_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend(
|
||||
{
|
||||
vol.Required(CONF_OPTIONS): {
|
||||
vol.Required(CONF_OPTION): vol.All(
|
||||
cv.ensure_list, vol.Length(min=1), [str]
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
SELECT_DOMAIN_SPECS = {DOMAIN: DomainSpec(), INPUT_SELECT_DOMAIN: DomainSpec()}
|
||||
|
||||
|
||||
class IsOptionSelectedCondition(EntityStateConditionBase):
|
||||
"""Condition for select option."""
|
||||
|
||||
_domain_specs = SELECT_DOMAIN_SPECS
|
||||
_schema = IS_OPTION_SELECTED_SCHEMA
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
|
||||
"""Initialize the option selected condition."""
|
||||
super().__init__(hass, config)
|
||||
if TYPE_CHECKING:
|
||||
assert config.options is not None
|
||||
self._states = set(config.options[CONF_OPTION])
|
||||
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"is_option_selected": IsOptionSelectedCondition,
|
||||
}
|
||||
|
||||
|
||||
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
|
||||
"""Return the select conditions."""
|
||||
return CONDITIONS
|
||||
26
homeassistant/components/select/conditions.yaml
Normal file
26
homeassistant/components/select/conditions.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
is_option_selected:
|
||||
target:
|
||||
entity:
|
||||
- domain: select
|
||||
- domain: input_select
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: condition_behavior
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
option:
|
||||
context:
|
||||
filter_target: target
|
||||
required: true
|
||||
selector:
|
||||
state:
|
||||
attribute: options
|
||||
hide_states:
|
||||
- unavailable
|
||||
- unknown
|
||||
multiple: true
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"conditions": {
|
||||
"is_option_selected": {
|
||||
"condition": "mdi:format-list-bulleted"
|
||||
}
|
||||
},
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"default": "mdi:format-list-bulleted"
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
{
|
||||
"conditions": {
|
||||
"is_option_selected": {
|
||||
"description": "Tests if one or more dropdowns have a specific option selected.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "Whether the condition should pass when any or all targeted entities match.",
|
||||
"name": "Behavior"
|
||||
},
|
||||
"option": {
|
||||
"description": "The options to check for.",
|
||||
"name": "Option"
|
||||
}
|
||||
},
|
||||
"name": "Option is selected"
|
||||
}
|
||||
},
|
||||
"device_automation": {
|
||||
"action_type": {
|
||||
"select_first": "Change {entity_name} to first option",
|
||||
@@ -36,6 +52,14 @@
|
||||
"message": "Option {option} is not valid for entity {entity_id}, valid options are: {options}."
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"condition_behavior": {
|
||||
"options": {
|
||||
"all": "All",
|
||||
"any": "Any"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"select_first": {
|
||||
"description": "Selects the first option of a select.",
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["sfrbox-api==0.1.0"]
|
||||
"requirements": ["sfrbox-api==0.1.1"]
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"name": "[%key:component::siren::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "If a siren is off"
|
||||
"name": "Siren is off"
|
||||
},
|
||||
"is_on": {
|
||||
"description": "Tests if one or more sirens are on.",
|
||||
@@ -24,7 +24,7 @@
|
||||
"name": "[%key:component::siren::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "If a siren is on"
|
||||
"name": "Siren is on"
|
||||
}
|
||||
},
|
||||
"entity_component": {
|
||||
|
||||
@@ -11,6 +11,7 @@ from homeassistant.const import (
|
||||
CONF_PLATFORM,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import Throttle
|
||||
@@ -94,11 +95,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmappeeConfigEntry) -> b
|
||||
)
|
||||
await hass.async_add_executor_job(smappee.load_local_service_location)
|
||||
else:
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
try:
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
)
|
||||
)
|
||||
)
|
||||
except config_entry_oauth2_flow.ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
|
||||
smappee_api = api.ConfigEntrySmappeeApi(hass, entry, implementation)
|
||||
|
||||
|
||||
@@ -43,5 +43,10 @@
|
||||
"title": "Discovered Smappee device"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
"""Provides conditions for switches."""
|
||||
|
||||
from homeassistant.components.input_boolean import DOMAIN as INPUT_BOOLEAN_DOMAIN
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.condition import Condition, make_entity_state_condition
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
SWITCH_DOMAIN_SPECS = {DOMAIN: DomainSpec(), INPUT_BOOLEAN_DOMAIN: DomainSpec()}
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"is_off": make_entity_state_condition(DOMAIN, STATE_OFF),
|
||||
"is_on": make_entity_state_condition(DOMAIN, STATE_ON),
|
||||
"is_off": make_entity_state_condition(SWITCH_DOMAIN_SPECS, STATE_OFF),
|
||||
"is_on": make_entity_state_condition(SWITCH_DOMAIN_SPECS, STATE_ON),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
.condition_common: &condition_common
|
||||
target:
|
||||
entity:
|
||||
domain: switch
|
||||
- domain: switch
|
||||
- domain: input_boolean
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
|
||||
@@ -315,7 +315,6 @@ async def make_device_data(
|
||||
)
|
||||
devices_data.binary_sensors.append((device, coordinator))
|
||||
devices_data.sensors.append((device, coordinator))
|
||||
|
||||
if isinstance(device, Device) and device.device_type == "AI Art Frame":
|
||||
coordinator = await coordinator_for_device(
|
||||
hass, entry, api, device, coordinators_by_id
|
||||
@@ -323,6 +322,11 @@ async def make_device_data(
|
||||
devices_data.buttons.append((device, coordinator))
|
||||
devices_data.sensors.append((device, coordinator))
|
||||
devices_data.images.append((device, coordinator))
|
||||
if isinstance(device, Device) and device.device_type == "WeatherStation":
|
||||
coordinator = await coordinator_for_device(
|
||||
hass, entry, api, device, coordinators_by_id
|
||||
)
|
||||
devices_data.sensors.append((device, coordinator))
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
@@ -257,6 +257,11 @@ SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = {
|
||||
),
|
||||
"Smart Radiator Thermostat": (BATTERY_DESCRIPTION,),
|
||||
"AI Art Frame": (BATTERY_DESCRIPTION,),
|
||||
"WeatherStation": (
|
||||
BATTERY_DESCRIPTION,
|
||||
TEMPERATURE_DESCRIPTION,
|
||||
HUMIDITY_DESCRIPTION,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -188,7 +188,7 @@ class OptionsFlowHandler(OptionsFlow):
|
||||
)
|
||||
|
||||
|
||||
class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
class TelegramBotConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Telegram."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
@@ -225,9 +225,9 @@ send_media_group:
|
||||
multiple: true
|
||||
label_field: url
|
||||
description_field: caption
|
||||
translation_key: "media"
|
||||
fields:
|
||||
media_type:
|
||||
label: Media type
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
@@ -237,20 +237,16 @@ send_media_group:
|
||||
- "video"
|
||||
translation_key: "media_type"
|
||||
caption:
|
||||
label: Caption
|
||||
selector:
|
||||
text:
|
||||
url:
|
||||
label: URL
|
||||
selector:
|
||||
text:
|
||||
type: url
|
||||
verify_ssl:
|
||||
label: Verify SSL
|
||||
selector:
|
||||
boolean:
|
||||
authentication:
|
||||
label: Authentication
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
@@ -259,16 +255,13 @@ send_media_group:
|
||||
- "bearer_token"
|
||||
translation_key: "authentication"
|
||||
username:
|
||||
label: Username
|
||||
selector:
|
||||
text:
|
||||
password:
|
||||
label: Password
|
||||
selector:
|
||||
text:
|
||||
type: password
|
||||
file:
|
||||
label: File
|
||||
selector:
|
||||
text:
|
||||
parse_mode:
|
||||
|
||||
@@ -279,6 +279,18 @@
|
||||
"upload_voice": "Uploading voice"
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"fields": {
|
||||
"authentication": "Authentication",
|
||||
"caption": "Caption",
|
||||
"file": "File",
|
||||
"media_type": "Media type",
|
||||
"password": "Password",
|
||||
"url": "URL",
|
||||
"username": "Username",
|
||||
"verify_ssl": "Verify SSL"
|
||||
}
|
||||
},
|
||||
"media_type": {
|
||||
"options": {
|
||||
"animation": "Animation",
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/thethingsnetwork",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["ttn_client==1.2.3"]
|
||||
"requirements": ["ttn_client==1.3.0"]
|
||||
}
|
||||
|
||||
17
homeassistant/components/timer/condition.py
Normal file
17
homeassistant/components/timer/condition.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Provides conditions for timers."""
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.condition import Condition, make_entity_state_condition
|
||||
|
||||
from . import DOMAIN, STATUS_ACTIVE, STATUS_IDLE, STATUS_PAUSED
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"is_active": make_entity_state_condition(DOMAIN, STATUS_ACTIVE),
|
||||
"is_paused": make_entity_state_condition(DOMAIN, STATUS_PAUSED),
|
||||
"is_idle": make_entity_state_condition(DOMAIN, STATUS_IDLE),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
|
||||
"""Return the timer conditions."""
|
||||
return CONDITIONS
|
||||
18
homeassistant/components/timer/conditions.yaml
Normal file
18
homeassistant/components/timer/conditions.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
.condition_common: &condition_common
|
||||
target:
|
||||
entity:
|
||||
- domain: timer
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: condition_behavior
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
|
||||
is_active: *condition_common
|
||||
is_paused: *condition_common
|
||||
is_idle: *condition_common
|
||||
@@ -1,4 +1,15 @@
|
||||
{
|
||||
"conditions": {
|
||||
"is_active": {
|
||||
"condition": "mdi:timer"
|
||||
},
|
||||
"is_idle": {
|
||||
"condition": "mdi:timer-off"
|
||||
},
|
||||
"is_paused": {
|
||||
"condition": "mdi:timer-pause"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"cancel": {
|
||||
"service": "mdi:cancel"
|
||||
|
||||
@@ -1,4 +1,40 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_description": "How the state should match on the targeted timers.",
|
||||
"condition_behavior_name": "Behavior"
|
||||
},
|
||||
"conditions": {
|
||||
"is_active": {
|
||||
"description": "Tests if one or more timers are active.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::timer::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::timer::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Timer is active"
|
||||
},
|
||||
"is_idle": {
|
||||
"description": "Tests if one or more timers are idle.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::timer::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::timer::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Timer is idle"
|
||||
},
|
||||
"is_paused": {
|
||||
"description": "Tests if one or more timers are paused.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::timer::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::timer::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Timer is paused"
|
||||
}
|
||||
},
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"name": "Timer",
|
||||
@@ -30,10 +66,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"condition_behavior": {
|
||||
"options": {
|
||||
"all": "All",
|
||||
"any": "Any"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"cancel": {
|
||||
"description": "Resets a timer's duration to the last known initial value without firing the timer finished event.",
|
||||
"name": "Cancel"
|
||||
"name": "Cancel timer"
|
||||
},
|
||||
"change": {
|
||||
"description": "Changes a timer by adding or subtracting a given duration.",
|
||||
@@ -43,19 +87,19 @@
|
||||
"name": "Duration"
|
||||
}
|
||||
},
|
||||
"name": "Change"
|
||||
"name": "Change timer"
|
||||
},
|
||||
"finish": {
|
||||
"description": "Finishes a running timer earlier than scheduled.",
|
||||
"name": "Finish"
|
||||
"name": "Finish timer"
|
||||
},
|
||||
"pause": {
|
||||
"description": "Pauses a running timer, retaining the remaining duration for later continuation.",
|
||||
"name": "[%key:common::action::pause%]"
|
||||
"name": "Pause timer"
|
||||
},
|
||||
"reload": {
|
||||
"description": "Reloads timers from the YAML-configuration.",
|
||||
"name": "[%key:common::action::reload%]"
|
||||
"name": "Reload timers"
|
||||
},
|
||||
"start": {
|
||||
"description": "Starts a timer or restarts it with a provided duration.",
|
||||
@@ -65,7 +109,7 @@
|
||||
"name": "Duration"
|
||||
}
|
||||
},
|
||||
"name": "[%key:common::action::start%]"
|
||||
"name": "Start timer"
|
||||
}
|
||||
},
|
||||
"title": "Timer"
|
||||
|
||||
@@ -167,146 +167,132 @@ class ItemTriggerBase(Trigger, abc.ABC):
|
||||
"""Handle entities being added/removed from the target."""
|
||||
|
||||
|
||||
class ItemAddedTrigger(ItemTriggerBase):
|
||||
class ItemChangeTriggerBase(ItemTriggerBase):
|
||||
"""todo item change trigger base class."""
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, config: TriggerConfig, description: str
|
||||
) -> None:
|
||||
"""Initialize trigger."""
|
||||
super().__init__(hass, config)
|
||||
self._entity_item_ids: dict[str, set[str] | None] = {}
|
||||
self._description = description
|
||||
|
||||
@abc.abstractmethod
|
||||
def _is_matching_item(self, item: TodoItem) -> bool:
|
||||
"""Return true if the item matches the trigger condition."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_items_diff(
|
||||
self, old_item_ids: set[str], current_item_ids: set[str]
|
||||
) -> set[str]:
|
||||
"""Return the set of item ids that should be reported for this trigger.
|
||||
|
||||
The calculation is based on the previous and current matching item ids.
|
||||
"""
|
||||
|
||||
@override
|
||||
@callback
|
||||
def _handle_item_change(
|
||||
self, event: TodoItemChangeEvent, run_action: TriggerActionRunner
|
||||
) -> None:
|
||||
"""Listen for todo item changes."""
|
||||
entity_id = event.entity_id
|
||||
if event.items is None:
|
||||
self._entity_item_ids[entity_id] = None
|
||||
return
|
||||
|
||||
old_item_ids = self._entity_item_ids.get(entity_id)
|
||||
current_item_ids = {
|
||||
item.uid
|
||||
for item in event.items
|
||||
if item.uid is not None and self._is_matching_item(item)
|
||||
}
|
||||
self._entity_item_ids[entity_id] = current_item_ids
|
||||
if old_item_ids is None:
|
||||
# Entity just became available, so no old items to compare against
|
||||
return
|
||||
|
||||
different_item_ids = self._get_items_diff(old_item_ids, current_item_ids)
|
||||
if different_item_ids:
|
||||
_LOGGER.debug(
|
||||
"Detected %s items with ids %s for entity %s",
|
||||
self._description,
|
||||
different_item_ids,
|
||||
entity_id,
|
||||
)
|
||||
payload = {
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"item_ids": sorted(different_item_ids),
|
||||
}
|
||||
run_action(payload, description=f"todo item {self._description} trigger")
|
||||
|
||||
@override
|
||||
@callback
|
||||
def _handle_entities_updated(self, tracked_entities: set[str]) -> None:
|
||||
"""Clear stale state for entities that left the tracked set."""
|
||||
for entity_id in set(self._entity_item_ids) - tracked_entities:
|
||||
del self._entity_item_ids[entity_id]
|
||||
|
||||
|
||||
class ItemAddedTrigger(ItemChangeTriggerBase):
|
||||
"""todo item added trigger."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize trigger."""
|
||||
super().__init__(hass, config)
|
||||
self._entity_item_ids: dict[str, set[str] | None] = {}
|
||||
super().__init__(hass, config, description="added")
|
||||
|
||||
@override
|
||||
@callback
|
||||
def _handle_item_change(
|
||||
self, event: TodoItemChangeEvent, run_action: TriggerActionRunner
|
||||
) -> None:
|
||||
"""Listen for todo item changes."""
|
||||
entity_id = event.entity_id
|
||||
if event.items is None:
|
||||
self._entity_item_ids[entity_id] = None
|
||||
return
|
||||
|
||||
old_item_ids = self._entity_item_ids.get(entity_id)
|
||||
current_item_ids = {item.uid for item in event.items if item.uid is not None}
|
||||
self._entity_item_ids[entity_id] = current_item_ids
|
||||
if old_item_ids is None:
|
||||
# Entity just became available, so no old items to compare against
|
||||
return
|
||||
added_item_ids = current_item_ids - old_item_ids
|
||||
if added_item_ids:
|
||||
_LOGGER.debug(
|
||||
"Detected added items with ids %s for entity %s",
|
||||
added_item_ids,
|
||||
entity_id,
|
||||
)
|
||||
payload = {
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"item_ids": sorted(added_item_ids),
|
||||
}
|
||||
run_action(payload, description="todo item added trigger")
|
||||
def _is_matching_item(self, item: TodoItem) -> bool:
|
||||
"""Return true if the item matches the trigger condition."""
|
||||
return True
|
||||
|
||||
@override
|
||||
@callback
|
||||
def _handle_entities_updated(self, tracked_entities: set[str]) -> None:
|
||||
"""Clear stale state for entities that left the tracked set."""
|
||||
for entity_id in set(self._entity_item_ids) - tracked_entities:
|
||||
del self._entity_item_ids[entity_id]
|
||||
def _get_items_diff(
|
||||
self, old_item_ids: set[str], current_item_ids: set[str]
|
||||
) -> set[str]:
|
||||
"""Return the set of item ids that match added items."""
|
||||
return current_item_ids - old_item_ids
|
||||
|
||||
|
||||
class ItemRemovedTrigger(ItemTriggerBase):
|
||||
class ItemRemovedTrigger(ItemChangeTriggerBase):
|
||||
"""todo item removed trigger."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize trigger."""
|
||||
super().__init__(hass, config)
|
||||
self._entity_item_ids: dict[str, set[str] | None] = {}
|
||||
super().__init__(hass, config, description="removed")
|
||||
|
||||
@override
|
||||
@callback
|
||||
def _handle_item_change(
|
||||
self, event: TodoItemChangeEvent, run_action: TriggerActionRunner
|
||||
) -> None:
|
||||
"""Listen for todo item changes."""
|
||||
entity_id = event.entity_id
|
||||
if event.items is None:
|
||||
self._entity_item_ids[entity_id] = None
|
||||
return
|
||||
|
||||
old_item_ids = self._entity_item_ids.get(entity_id)
|
||||
current_item_ids = {item.uid for item in event.items if item.uid is not None}
|
||||
self._entity_item_ids[entity_id] = current_item_ids
|
||||
if old_item_ids is None:
|
||||
# Entity just became available, so no old items to compare against
|
||||
return
|
||||
removed_item_ids = old_item_ids - current_item_ids
|
||||
if removed_item_ids:
|
||||
_LOGGER.debug(
|
||||
"Detected removed items with ids %s for entity %s",
|
||||
removed_item_ids,
|
||||
entity_id,
|
||||
)
|
||||
payload = {
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"item_ids": sorted(removed_item_ids),
|
||||
}
|
||||
run_action(payload, description="todo item removed trigger")
|
||||
def _is_matching_item(self, item: TodoItem) -> bool:
|
||||
"""Return true if the item matches the trigger condition."""
|
||||
return True
|
||||
|
||||
@override
|
||||
@callback
|
||||
def _handle_entities_updated(self, tracked_entities: set[str]) -> None:
|
||||
"""Clear stale state for entities that left the tracked set."""
|
||||
for entity_id in set(self._entity_item_ids) - tracked_entities:
|
||||
del self._entity_item_ids[entity_id]
|
||||
def _get_items_diff(
|
||||
self, old_item_ids: set[str], current_item_ids: set[str]
|
||||
) -> set[str]:
|
||||
"""Return the set of item ids that match removed items."""
|
||||
return old_item_ids - current_item_ids
|
||||
|
||||
|
||||
class ItemCompletedTrigger(ItemTriggerBase):
|
||||
class ItemCompletedTrigger(ItemChangeTriggerBase):
|
||||
"""todo item completed trigger."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize trigger."""
|
||||
super().__init__(hass, config)
|
||||
self._entity_completed_item_ids: dict[str, set[str] | None] = {}
|
||||
super().__init__(hass, config, description="completed")
|
||||
|
||||
@override
|
||||
@callback
|
||||
def _handle_item_change(
|
||||
self, event: TodoItemChangeEvent, run_action: TriggerActionRunner
|
||||
) -> None:
|
||||
"""Listen for todo item changes."""
|
||||
entity_id = event.entity_id
|
||||
if event.items is None:
|
||||
self._entity_completed_item_ids[entity_id] = None
|
||||
return
|
||||
|
||||
old_item_ids = self._entity_completed_item_ids.get(entity_id)
|
||||
current_item_ids = {
|
||||
item.uid
|
||||
for item in event.items
|
||||
if item.uid is not None and item.status == TodoItemStatus.COMPLETED
|
||||
}
|
||||
self._entity_completed_item_ids[entity_id] = current_item_ids
|
||||
if old_item_ids is None:
|
||||
# Entity just became available, so no old items to compare against
|
||||
return
|
||||
new_completed_item_ids = current_item_ids - old_item_ids
|
||||
if new_completed_item_ids:
|
||||
_LOGGER.debug(
|
||||
"Detected new completed items with ids %s for entity %s",
|
||||
new_completed_item_ids,
|
||||
entity_id,
|
||||
)
|
||||
payload = {
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"item_ids": sorted(new_completed_item_ids),
|
||||
}
|
||||
run_action(payload, description="todo item completed trigger")
|
||||
def _is_matching_item(self, item: TodoItem) -> bool:
|
||||
"""Return true if the item matches the trigger condition."""
|
||||
return item.status == TodoItemStatus.COMPLETED
|
||||
|
||||
@override
|
||||
@callback
|
||||
def _handle_entities_updated(self, tracked_entities: set[str]) -> None:
|
||||
"""Clear stale state for entities that left the tracked set."""
|
||||
for entity_id in set(self._entity_completed_item_ids) - tracked_entities:
|
||||
del self._entity_completed_item_ids[entity_id]
|
||||
def _get_items_diff(
|
||||
self, old_item_ids: set[str], current_item_ids: set[str]
|
||||
) -> set[str]:
|
||||
"""Return the set of item ids that match completed items."""
|
||||
return current_item_ids - old_item_ids
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
|
||||
@@ -1,172 +1,30 @@
|
||||
"""Support for TP-Link LTE modems."""
|
||||
"""The tplink_lte integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import attr
|
||||
import tp_connected
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_RECIPIENT,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv, discovery
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "tplink_lte"
|
||||
DATA_KEY = "tplink_lte"
|
||||
|
||||
CONF_NOTIFY = "notify"
|
||||
|
||||
_NOTIFY_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_RECIPIENT): vol.All(cv.ensure_list, [cv.string]),
|
||||
}
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
DOMAIN: vol.All(
|
||||
cv.ensure_list,
|
||||
[
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Required(CONF_PASSWORD): cv.string,
|
||||
vol.Optional(CONF_NOTIFY): vol.All(
|
||||
cv.ensure_list, [_NOTIFY_SCHEMA]
|
||||
),
|
||||
}
|
||||
)
|
||||
],
|
||||
)
|
||||
},
|
||||
{DOMAIN: cv.match_all},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
|
||||
@attr.s
|
||||
class ModemData:
|
||||
"""Class for modem state."""
|
||||
|
||||
host: str = attr.ib()
|
||||
modem: tp_connected.Modem = attr.ib()
|
||||
|
||||
connected: bool = attr.ib(init=False, default=True)
|
||||
|
||||
|
||||
@attr.s
|
||||
class LTEData:
|
||||
"""Shared state."""
|
||||
|
||||
websession: aiohttp.ClientSession = attr.ib()
|
||||
modem_data: dict[str, ModemData] = attr.ib(init=False, factory=dict)
|
||||
|
||||
def get_modem_data(self, config: dict[str, Any]) -> ModemData | None:
|
||||
"""Get the requested or the only modem_data value."""
|
||||
if CONF_HOST in config:
|
||||
return self.modem_data.get(config[CONF_HOST])
|
||||
if len(self.modem_data) == 1:
|
||||
return next(iter(self.modem_data.values()))
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up TP-Link LTE component."""
|
||||
if DATA_KEY not in hass.data:
|
||||
websession = async_create_clientsession(
|
||||
hass, cookie_jar=aiohttp.CookieJar(unsafe=True)
|
||||
)
|
||||
hass.data[DATA_KEY] = LTEData(websession)
|
||||
|
||||
domain_config = config.get(DOMAIN, [])
|
||||
|
||||
tasks = [_setup_lte(hass, conf) for conf in domain_config]
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
for conf in domain_config:
|
||||
for notify_conf in conf.get(CONF_NOTIFY, []):
|
||||
hass.async_create_task(
|
||||
discovery.async_load_platform(
|
||||
hass, Platform.NOTIFY, DOMAIN, notify_conf, config
|
||||
)
|
||||
)
|
||||
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
DOMAIN,
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
translation_key="integration_removed",
|
||||
translation_placeholders={
|
||||
"ghsa_url": "https://github.com/advisories/GHSA-h95x-26f3-88hr",
|
||||
},
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def _setup_lte(
|
||||
hass: HomeAssistant, lte_config: dict[str, Any], delay: int = 0
|
||||
) -> None:
|
||||
"""Set up a TP-Link LTE modem."""
|
||||
|
||||
host: str = lte_config[CONF_HOST]
|
||||
password: str = lte_config[CONF_PASSWORD]
|
||||
|
||||
lte_data: LTEData = hass.data[DATA_KEY]
|
||||
modem = tp_connected.Modem(hostname=host, websession=lte_data.websession)
|
||||
|
||||
modem_data = ModemData(host, modem)
|
||||
|
||||
try:
|
||||
await _login(hass, modem_data, password)
|
||||
except tp_connected.Error:
|
||||
retry_task = hass.loop.create_task(_retry_login(hass, modem_data, password))
|
||||
|
||||
@callback
|
||||
def cleanup_retry(event: Event) -> None:
|
||||
"""Clean up retry task resources."""
|
||||
if not retry_task.done():
|
||||
retry_task.cancel()
|
||||
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup_retry)
|
||||
|
||||
|
||||
async def _login(hass: HomeAssistant, modem_data: ModemData, password: str) -> None:
|
||||
"""Log in and complete setup."""
|
||||
await modem_data.modem.login(password=password)
|
||||
modem_data.connected = True
|
||||
lte_data: LTEData = hass.data[DATA_KEY]
|
||||
lte_data.modem_data[modem_data.host] = modem_data
|
||||
|
||||
async def cleanup(event: Event) -> None:
|
||||
"""Clean up resources."""
|
||||
await modem_data.modem.logout()
|
||||
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup)
|
||||
|
||||
|
||||
async def _retry_login(
|
||||
hass: HomeAssistant, modem_data: ModemData, password: str
|
||||
) -> None:
|
||||
"""Sleep and retry setup."""
|
||||
|
||||
_LOGGER.warning("Could not connect to %s. Will keep trying", modem_data.host)
|
||||
|
||||
modem_data.connected = False
|
||||
delay = 15
|
||||
|
||||
while not modem_data.connected:
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
try:
|
||||
await _login(hass, modem_data, password)
|
||||
_LOGGER.warning("Connected to %s", modem_data.host)
|
||||
except tp_connected.Error:
|
||||
delay = min(2 * delay, 300)
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"codeowners": [],
|
||||
"documentation": "https://www.home-assistant.io/integrations/tplink_lte",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["tp_connected"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["tp-connected==0.0.4"]
|
||||
"requirements": []
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
"""Support for TP-Link LTE notifications."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import attr
|
||||
import tp_connected
|
||||
|
||||
from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService
|
||||
from homeassistant.const import CONF_RECIPIENT
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from . import DATA_KEY, LTEData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_get_service(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> TplinkNotifyService | None:
|
||||
"""Get the notification service."""
|
||||
if discovery_info is None:
|
||||
return None
|
||||
return TplinkNotifyService(hass, discovery_info)
|
||||
|
||||
|
||||
@attr.s
|
||||
class TplinkNotifyService(BaseNotificationService):
|
||||
"""Implementation of a notification service."""
|
||||
|
||||
hass: HomeAssistant = attr.ib()
|
||||
config: dict[str, Any] = attr.ib()
|
||||
|
||||
async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
|
||||
"""Send a message to a user."""
|
||||
|
||||
lte_data: LTEData = self.hass.data[DATA_KEY]
|
||||
modem_data = lte_data.get_modem_data(self.config)
|
||||
if not modem_data:
|
||||
_LOGGER.error("No modem available")
|
||||
return
|
||||
|
||||
phone = self.config[CONF_RECIPIENT]
|
||||
targets = kwargs.get(ATTR_TARGET, phone)
|
||||
if targets and message:
|
||||
for target in targets:
|
||||
try:
|
||||
await modem_data.modem.sms(target, message)
|
||||
except tp_connected.Error:
|
||||
_LOGGER.error("Unable to send to %s", target)
|
||||
8
homeassistant/components/tplink_lte/strings.json
Normal file
8
homeassistant/components/tplink_lte/strings.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"issues": {
|
||||
"integration_removed": {
|
||||
"description": "The TP-Link LTE integration has been removed from Home Assistant.\n\nThe integration has not been working since Home Assistant 2023.6.0, has no maintainer, and its underlying library depends on a package with a [critical security vulnerability]({ghsa_url}).\n\nTo resolve this issue, remove the `tplink_lte` configuration from your `configuration.yaml` file and restart Home Assistant.",
|
||||
"title": "The TP-Link LTE integration has been removed"
|
||||
}
|
||||
}
|
||||
}
|
||||
20
homeassistant/components/valve/condition.py
Normal file
20
homeassistant/components/valve/condition.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Provides conditions for valves."""
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.condition import Condition, make_entity_state_condition
|
||||
|
||||
from . import ATTR_IS_CLOSED
|
||||
from .const import DOMAIN
|
||||
|
||||
VALVE_DOMAIN_SPECS = {DOMAIN: DomainSpec(value_source=ATTR_IS_CLOSED)}
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"is_open": make_entity_state_condition(VALVE_DOMAIN_SPECS, False),
|
||||
"is_closed": make_entity_state_condition(VALVE_DOMAIN_SPECS, True),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
|
||||
"""Return the valve conditions."""
|
||||
return CONDITIONS
|
||||
17
homeassistant/components/valve/conditions.yaml
Normal file
17
homeassistant/components/valve/conditions.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
.condition_common: &condition_common
|
||||
target:
|
||||
entity:
|
||||
- domain: valve
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: condition_behavior
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
|
||||
is_open: *condition_common
|
||||
is_closed: *condition_common
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user