mirror of
https://github.com/home-assistant/core.git
synced 2025-12-18 13:58:01 +00:00
Compare commits
80 Commits
knx-ui-fan
...
lock/add-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e70a3e05a0 | ||
|
|
3463139b18 | ||
|
|
c418d9750b | ||
|
|
e96d614076 | ||
|
|
f0a5e0a023 | ||
|
|
6ac6b86060 | ||
|
|
3909171b1a | ||
|
|
769029505f | ||
|
|
080ec3524b | ||
|
|
48d671ad5f | ||
|
|
7115db5d22 | ||
|
|
d0c8792e4b | ||
|
|
84d7c37502 | ||
|
|
8a10638470 | ||
|
|
10dd53ffc2 | ||
|
|
36aefce9e1 | ||
|
|
fe34da19e2 | ||
|
|
fe94dea1db | ||
|
|
3f57b46756 | ||
|
|
7e141533bb | ||
|
|
391ccbafae | ||
|
|
6af674e64e | ||
|
|
7b1653c77b | ||
|
|
c87dafa2e6 | ||
|
|
8375acf315 | ||
|
|
4df5a41b57 | ||
|
|
5796b4c0d9 | ||
|
|
5f4f07803b | ||
|
|
a0a444e3c8 | ||
|
|
30cfe987ed | ||
|
|
412ee0da05 | ||
|
|
d6b675138d | ||
|
|
bde3cef17d | ||
|
|
412ee30584 | ||
|
|
7eecdc87fd | ||
|
|
9ba252d8e3 | ||
|
|
1709a9d255 | ||
|
|
bcf46f09a2 | ||
|
|
d4097a8686 | ||
|
|
2a92292e76 | ||
|
|
fe987a63d6 | ||
|
|
91f3b991ba | ||
|
|
46c6313068 | ||
|
|
86e4a81934 | ||
|
|
234d6ae161 | ||
|
|
2ab203618e | ||
|
|
faae23ee1b | ||
|
|
f6acd4f230 | ||
|
|
71d36a6496 | ||
|
|
9fc014c6f4 | ||
|
|
537f93872c | ||
|
|
06a55175a8 | ||
|
|
5f37016baa | ||
|
|
1af884293f | ||
|
|
ba73ab38e8 | ||
|
|
2d33a720f7 | ||
|
|
dbfdaf6a2e | ||
|
|
278cb4d3ae | ||
|
|
1c6f8b7e54 | ||
|
|
731f5078a6 | ||
|
|
9863d3484d | ||
|
|
f85a684e31 | ||
|
|
e292a67692 | ||
|
|
c82d159c14 | ||
|
|
d890387d3d | ||
|
|
d996d7b113 | ||
|
|
d28a4598d5 | ||
|
|
229f7c4f37 | ||
|
|
9f2138aa18 | ||
|
|
7506ff826c | ||
|
|
317a3ed044 | ||
|
|
d7801881e9 | ||
|
|
a4bbdafd55 | ||
|
|
97673f22cb | ||
|
|
d63cdafad2 | ||
|
|
50f47a7397 | ||
|
|
123d573274 | ||
|
|
64ccde6709 | ||
|
|
c69ef7e1f6 | ||
|
|
d51cca3325 |
1
CODEOWNERS
generated
1
CODEOWNERS
generated
@@ -665,6 +665,7 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/here_travel_time/ @eifinger
|
||||
/tests/components/here_travel_time/ @eifinger
|
||||
/homeassistant/components/hikvision/ @mezz64
|
||||
/tests/components/hikvision/ @mezz64
|
||||
/homeassistant/components/hikvisioncam/ @fbradyirl
|
||||
/homeassistant/components/hisense_aehw4a1/ @bannhead
|
||||
/tests/components/hisense_aehw4a1/ @bannhead
|
||||
|
||||
@@ -624,13 +624,16 @@ async def async_enable_logging(
|
||||
|
||||
if log_file is None:
|
||||
default_log_path = hass.config.path(ERROR_LOG_FILENAME)
|
||||
if "SUPERVISOR" in os.environ:
|
||||
_LOGGER.info("Running in Supervisor, not logging to file")
|
||||
if "SUPERVISOR" in os.environ and "HA_DUPLICATE_LOG_FILE" not in os.environ:
|
||||
# Rename the default log file if it exists, since previous versions created
|
||||
# it even on Supervisor
|
||||
if os.path.isfile(default_log_path):
|
||||
with contextlib.suppress(OSError):
|
||||
os.rename(default_log_path, f"{default_log_path}.old")
|
||||
def rename_old_file() -> None:
|
||||
"""Rename old log file in executor."""
|
||||
if os.path.isfile(default_log_path):
|
||||
with contextlib.suppress(OSError):
|
||||
os.rename(default_log_path, f"{default_log_path}.old")
|
||||
|
||||
await hass.async_add_executor_job(rename_old_file)
|
||||
err_log_path = None
|
||||
else:
|
||||
err_log_path = default_log_path
|
||||
|
||||
@@ -9,8 +9,9 @@ from actron_neo_api import (
|
||||
|
||||
from homeassistant.const import CONF_API_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
|
||||
from .const import _LOGGER
|
||||
from .const import _LOGGER, DOMAIN
|
||||
from .coordinator import (
|
||||
ActronAirConfigEntry,
|
||||
ActronAirRuntimeData,
|
||||
@@ -29,12 +30,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) ->
|
||||
try:
|
||||
systems = await api.get_ac_systems()
|
||||
await api.update_status()
|
||||
except ActronAirAuthError:
|
||||
_LOGGER.error("Authentication error while setting up Actron Air integration")
|
||||
raise
|
||||
except ActronAirAuthError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_error",
|
||||
) from err
|
||||
except ActronAirAPIError as err:
|
||||
_LOGGER.error("API error while setting up Actron Air integration: %s", err)
|
||||
raise
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
system_coordinators: dict[str, ActronAirSystemCoordinator] = {}
|
||||
for system in systems:
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""Setup config flow for Actron Air integration."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from actron_neo_api import ActronAirAPI, ActronAirAuthError
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_API_TOKEN
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
@@ -95,8 +96,16 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
unique_id = str(user_data["id"])
|
||||
await self.async_set_unique_id(unique_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
# Check if this is a reauth flow
|
||||
if self.source == SOURCE_REAUTH:
|
||||
self._abort_if_unique_id_mismatch(reason="wrong_account")
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(),
|
||||
data_updates={CONF_API_TOKEN: self._api.refresh_token_value},
|
||||
)
|
||||
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=user_data["email"],
|
||||
data={CONF_API_TOKEN: self._api.refresh_token_value},
|
||||
@@ -114,6 +123,21 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
del self.login_task
|
||||
return await self.async_step_user()
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthentication request."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm reauth dialog."""
|
||||
if user_input is not None:
|
||||
return await self.async_step_user()
|
||||
|
||||
return self.async_show_form(step_id="reauth_confirm")
|
||||
|
||||
async def async_step_connection_error(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
|
||||
@@ -5,16 +5,23 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
from actron_neo_api import ActronAirACSystem, ActronAirAPI, ActronAirStatus
|
||||
from actron_neo_api import (
|
||||
ActronAirACSystem,
|
||||
ActronAirAPI,
|
||||
ActronAirAuthError,
|
||||
ActronAirStatus,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import _LOGGER
|
||||
from .const import _LOGGER, DOMAIN
|
||||
|
||||
STALE_DEVICE_TIMEOUT = timedelta(hours=24)
|
||||
SCAN_INTERVAL = timedelta(seconds=30)
|
||||
STALE_DEVICE_TIMEOUT = timedelta(minutes=5)
|
||||
ERROR_NO_SYSTEMS_FOUND = "no_systems_found"
|
||||
ERROR_UNKNOWN = "unknown_error"
|
||||
|
||||
@@ -29,9 +36,6 @@ class ActronAirRuntimeData:
|
||||
|
||||
type ActronAirConfigEntry = ConfigEntry[ActronAirRuntimeData]
|
||||
|
||||
AUTH_ERROR_THRESHOLD = 3
|
||||
SCAN_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
|
||||
class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]):
|
||||
"""System coordinator for Actron Air integration."""
|
||||
@@ -59,7 +63,14 @@ class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]):
|
||||
|
||||
async def _async_update_data(self) -> ActronAirStatus:
|
||||
"""Fetch updates and merge incremental changes into the full state."""
|
||||
await self.api.update_status()
|
||||
try:
|
||||
await self.api.update_status()
|
||||
except ActronAirAuthError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_error",
|
||||
) from err
|
||||
|
||||
self.status = self.api.state_manager.get_status(self.serial_number)
|
||||
self.last_seen = dt_util.utcnow()
|
||||
return self.status
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["actron-neo-api==0.1.87"]
|
||||
"requirements": ["actron-neo-api==0.2.0"]
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ rules:
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
reauthentication-flow: done
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"oauth2_error": "Failed to start OAuth2 flow"
|
||||
"oauth2_error": "Failed to start authentication flow",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"wrong_account": "You must reauthenticate with the same Actron Air account that was originally configured."
|
||||
},
|
||||
"error": {
|
||||
"oauth2_error": "Failed to start OAuth2 flow. Please try again later."
|
||||
"oauth2_error": "Failed to start authentication flow. Please try again later."
|
||||
},
|
||||
"progress": {
|
||||
"wait_for_authorization": "To authenticate, open the following URL and login at Actron Air:\n{verification_uri}\nIf the code is not automatically copied, paste the following code to authorize the integration:\n\n```{user_code}```\n\n\nThe login attempt will time out after {expires_minutes} minutes."
|
||||
@@ -16,14 +18,23 @@
|
||||
"description": "Failed to connect to Actron Air. Please check your internet connection and try again.",
|
||||
"title": "Connection error"
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"description": "Your Actron Air authentication has expired. Select continue to reauthenticate with your Actron Air account. You will be prompted to log in again to restore the connection.",
|
||||
"title": "Authentication expired"
|
||||
},
|
||||
"timeout": {
|
||||
"data": {},
|
||||
"description": "The authorization process timed out. Please try again.",
|
||||
"title": "Authorization timeout"
|
||||
"description": "The authentication process timed out. Please try again.",
|
||||
"title": "Authentication timeout"
|
||||
},
|
||||
"user": {
|
||||
"title": "Actron Air OAuth2 Authorization"
|
||||
"title": "Actron Air Authentication"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"auth_error": {
|
||||
"message": "Authentication failed, please reauthenticate"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity import get_supported_features
|
||||
from homeassistant.helpers.trigger import (
|
||||
EntityStateTriggerBase,
|
||||
EntityTargetStateTriggerBase,
|
||||
Trigger,
|
||||
make_conditional_entity_state_trigger,
|
||||
make_entity_state_trigger,
|
||||
make_entity_target_state_trigger,
|
||||
make_entity_transition_trigger,
|
||||
)
|
||||
|
||||
from .const import DOMAIN, AlarmControlPanelEntityFeature, AlarmControlPanelState
|
||||
@@ -21,7 +21,7 @@ def supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> bool
|
||||
return False
|
||||
|
||||
|
||||
class EntityStateTriggerRequiredFeatures(EntityStateTriggerBase):
|
||||
class EntityStateTriggerRequiredFeatures(EntityTargetStateTriggerBase):
|
||||
"""Trigger for entity state changes."""
|
||||
|
||||
_required_features: int
|
||||
@@ -38,7 +38,7 @@ class EntityStateTriggerRequiredFeatures(EntityStateTriggerBase):
|
||||
|
||||
def make_entity_state_trigger_required_features(
|
||||
domain: str, to_state: str, required_features: int
|
||||
) -> type[EntityStateTriggerBase]:
|
||||
) -> type[EntityTargetStateTriggerBase]:
|
||||
"""Create an entity state trigger class."""
|
||||
|
||||
class CustomTrigger(EntityStateTriggerRequiredFeatures):
|
||||
@@ -52,7 +52,7 @@ def make_entity_state_trigger_required_features(
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"armed": make_conditional_entity_state_trigger(
|
||||
"armed": make_entity_transition_trigger(
|
||||
DOMAIN,
|
||||
from_states={
|
||||
AlarmControlPanelState.ARMING,
|
||||
@@ -89,8 +89,12 @@ TRIGGERS: dict[str, type[Trigger]] = {
|
||||
AlarmControlPanelState.ARMED_VACATION,
|
||||
AlarmControlPanelEntityFeature.ARM_VACATION,
|
||||
),
|
||||
"disarmed": make_entity_state_trigger(DOMAIN, AlarmControlPanelState.DISARMED),
|
||||
"triggered": make_entity_state_trigger(DOMAIN, AlarmControlPanelState.TRIGGERED),
|
||||
"disarmed": make_entity_target_state_trigger(
|
||||
DOMAIN, AlarmControlPanelState.DISARMED
|
||||
),
|
||||
"triggered": make_entity_target_state_trigger(
|
||||
DOMAIN, AlarmControlPanelState.TRIGGERED
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
"""Provides triggers for assist satellites."""
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.trigger import Trigger, make_entity_state_trigger
|
||||
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
|
||||
|
||||
from .const import DOMAIN
|
||||
from .entity import AssistSatelliteState
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"idle": make_entity_state_trigger(DOMAIN, AssistSatelliteState.IDLE),
|
||||
"listening": make_entity_state_trigger(DOMAIN, AssistSatelliteState.LISTENING),
|
||||
"processing": make_entity_state_trigger(DOMAIN, AssistSatelliteState.PROCESSING),
|
||||
"responding": make_entity_state_trigger(DOMAIN, AssistSatelliteState.RESPONDING),
|
||||
"idle": make_entity_target_state_trigger(DOMAIN, AssistSatelliteState.IDLE),
|
||||
"listening": make_entity_target_state_trigger(
|
||||
DOMAIN, AssistSatelliteState.LISTENING
|
||||
),
|
||||
"processing": make_entity_target_state_trigger(
|
||||
DOMAIN, AssistSatelliteState.PROCESSING
|
||||
),
|
||||
"responding": make_entity_target_state_trigger(
|
||||
DOMAIN, AssistSatelliteState.RESPONDING
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,5 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/autarco",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["autarco==3.2.0"]
|
||||
}
|
||||
|
||||
@@ -128,9 +128,11 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"button",
|
||||
"climate",
|
||||
"cover",
|
||||
"device_tracker",
|
||||
"fan",
|
||||
"lawn_mower",
|
||||
"light",
|
||||
"lock",
|
||||
"media_player",
|
||||
"switch",
|
||||
"text",
|
||||
|
||||
@@ -4,7 +4,7 @@ from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity import get_device_class
|
||||
from homeassistant.helpers.trigger import EntityStateTriggerBase, Trigger
|
||||
from homeassistant.helpers.trigger import EntityTargetStateTriggerBase, Trigger
|
||||
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
|
||||
|
||||
from . import DOMAIN, BinarySensorDeviceClass
|
||||
@@ -20,7 +20,7 @@ def get_device_class_or_undefined(
|
||||
return UNDEFINED
|
||||
|
||||
|
||||
class BinarySensorOnOffTrigger(EntityStateTriggerBase):
|
||||
class BinarySensorOnOffTrigger(EntityTargetStateTriggerBase):
|
||||
"""Class for binary sensor on/off triggers."""
|
||||
|
||||
_device_class: BinarySensorDeviceClass | None
|
||||
|
||||
@@ -3,22 +3,22 @@
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.trigger import (
|
||||
Trigger,
|
||||
make_conditional_entity_state_trigger,
|
||||
make_entity_state_attribute_trigger,
|
||||
make_entity_state_trigger,
|
||||
make_entity_target_state_attribute_trigger,
|
||||
make_entity_target_state_trigger,
|
||||
make_entity_transition_trigger,
|
||||
)
|
||||
|
||||
from .const import ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"started_cooling": make_entity_state_attribute_trigger(
|
||||
"started_cooling": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING
|
||||
),
|
||||
"started_drying": make_entity_state_attribute_trigger(
|
||||
"started_drying": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
|
||||
),
|
||||
"turned_off": make_entity_state_trigger(DOMAIN, HVACMode.OFF),
|
||||
"turned_on": make_conditional_entity_state_trigger(
|
||||
"turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF),
|
||||
"turned_on": make_entity_transition_trigger(
|
||||
DOMAIN,
|
||||
from_states={
|
||||
HVACMode.OFF,
|
||||
@@ -32,7 +32,7 @@ TRIGGERS: dict[str, type[Trigger]] = {
|
||||
HVACMode.HEAT_COOL,
|
||||
},
|
||||
),
|
||||
"started_heating": make_entity_state_attribute_trigger(
|
||||
"started_heating": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.HEATING
|
||||
),
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["compit"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["compit-inext-api==0.3.1"]
|
||||
"requirements": ["compit-inext-api==0.3.4"]
|
||||
}
|
||||
|
||||
@@ -65,8 +65,10 @@ def websocket_create_area(
|
||||
data.pop("id")
|
||||
|
||||
if "aliases" in data:
|
||||
# Convert aliases to a set
|
||||
data["aliases"] = set(data["aliases"])
|
||||
# Create a set for the aliases without:
|
||||
# - Empty strings
|
||||
# - Trailing and leading whitespace characters in the individual aliases
|
||||
data["aliases"] = {s_strip for s in data["aliases"] if (s_strip := s.strip())}
|
||||
|
||||
if "labels" in data:
|
||||
# Convert labels to a set
|
||||
@@ -133,8 +135,10 @@ def websocket_update_area(
|
||||
data.pop("id")
|
||||
|
||||
if "aliases" in data:
|
||||
# Convert aliases to a set
|
||||
data["aliases"] = set(data["aliases"])
|
||||
# Create a set for the aliases without:
|
||||
# - Empty strings
|
||||
# - Trailing and leading whitespace characters in the individual aliases
|
||||
data["aliases"] = {s_strip for s in data["aliases"] if (s_strip := s.strip())}
|
||||
|
||||
if "labels" in data:
|
||||
# Convert labels to a set
|
||||
|
||||
@@ -227,8 +227,10 @@ def websocket_update_entity(
|
||||
changes[key] = msg[key]
|
||||
|
||||
if "aliases" in msg:
|
||||
# Convert aliases to a set
|
||||
changes["aliases"] = set(msg["aliases"])
|
||||
# Create a set for the aliases without:
|
||||
# - Empty strings
|
||||
# - Trailing and leading whitespace characters in the individual aliases
|
||||
changes["aliases"] = {s_strip for s in msg["aliases"] if (s_strip := s.strip())}
|
||||
|
||||
if "labels" in msg:
|
||||
# Convert labels to a set
|
||||
|
||||
@@ -61,8 +61,10 @@ def websocket_create_floor(
|
||||
data.pop("id")
|
||||
|
||||
if "aliases" in data:
|
||||
# Convert aliases to a set
|
||||
data["aliases"] = set(data["aliases"])
|
||||
# Create a set for the aliases without:
|
||||
# - Empty strings
|
||||
# - Trailing and leading whitespace characters in the individual aliases
|
||||
data["aliases"] = {s_strip for s in data["aliases"] if (s_strip := s.strip())}
|
||||
|
||||
try:
|
||||
entry = registry.async_create(**data)
|
||||
@@ -117,8 +119,10 @@ def websocket_update_floor(
|
||||
data.pop("id")
|
||||
|
||||
if "aliases" in data:
|
||||
# Convert aliases to a set
|
||||
data["aliases"] = set(data["aliases"])
|
||||
# Create a set for the aliases without:
|
||||
# - Empty strings
|
||||
# - Trailing and leading whitespace characters in the individual aliases
|
||||
data["aliases"] = {s_strip for s in data["aliases"] if (s_strip := s.strip())}
|
||||
|
||||
try:
|
||||
entry = registry.async_update(**data)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Protocol
|
||||
from typing import Any, Protocol
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -11,18 +11,15 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.condition import (
|
||||
Condition,
|
||||
ConditionChecker,
|
||||
ConditionCheckerType,
|
||||
ConditionConfig,
|
||||
trace_condition_function,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
|
||||
|
||||
from . import DeviceAutomationType, async_get_device_automation_platform
|
||||
from .helpers import async_validate_device_automation_config
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.helpers import condition
|
||||
|
||||
|
||||
class DeviceAutomationConditionProtocol(Protocol):
|
||||
"""Define the format of device_condition modules.
|
||||
@@ -90,15 +87,21 @@ class DeviceCondition(Condition):
|
||||
assert config.options is not None
|
||||
self._config = config.options
|
||||
|
||||
async def async_get_checker(self) -> condition.ConditionCheckerType:
|
||||
async def async_get_checker(self) -> ConditionChecker:
|
||||
"""Test a device condition."""
|
||||
platform = await async_get_device_automation_platform(
|
||||
self._hass, self._config[CONF_DOMAIN], DeviceAutomationType.CONDITION
|
||||
)
|
||||
return trace_condition_function(
|
||||
platform.async_condition_from_config(self._hass, self._config)
|
||||
platform_checker = platform.async_condition_from_config(
|
||||
self._hass, self._config
|
||||
)
|
||||
|
||||
def checker(variables: TemplateVarsType = None, **kwargs: Any) -> bool:
|
||||
result = platform_checker(self._hass, variables)
|
||||
return result is not False
|
||||
|
||||
return checker
|
||||
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"_device": DeviceCondition,
|
||||
|
||||
@@ -11,5 +11,13 @@
|
||||
"see": {
|
||||
"service": "mdi:account-eye"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"entered_home": {
|
||||
"trigger": "mdi:account-arrow-left"
|
||||
},
|
||||
"left_home": {
|
||||
"trigger": "mdi:account-arrow-right"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"common": {
|
||||
"trigger_behavior_description": "The behavior of the targeted device trackers to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"device_automation": {
|
||||
"condition_type": {
|
||||
"is_home": "{entity_name} is home",
|
||||
@@ -44,6 +48,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
"first": "First",
|
||||
"last": "Last"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"see": {
|
||||
"description": "Manually update the records of a seen legacy device tracker in the known_devices.yaml file.",
|
||||
@@ -80,5 +93,27 @@
|
||||
"name": "See"
|
||||
}
|
||||
},
|
||||
"title": "Device tracker"
|
||||
"title": "Device tracker",
|
||||
"triggers": {
|
||||
"entered_home": {
|
||||
"description": "Triggers when one or more device trackers enter home.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::device_tracker::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::device_tracker::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Entered home"
|
||||
},
|
||||
"left_home": {
|
||||
"description": "Triggers when one or more device trackers leave home.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::device_tracker::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::device_tracker::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Left home"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
21
homeassistant/components/device_tracker/trigger.py
Normal file
21
homeassistant/components/device_tracker/trigger.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Provides triggers for device_trackers."""
|
||||
|
||||
from homeassistant.const import STATE_HOME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.trigger import (
|
||||
Trigger,
|
||||
make_entity_origin_state_trigger,
|
||||
make_entity_target_state_trigger,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"entered_home": make_entity_target_state_trigger(DOMAIN, STATE_HOME),
|
||||
"left_home": make_entity_origin_state_trigger(DOMAIN, from_state=STATE_HOME),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for device trackers."""
|
||||
return TRIGGERS
|
||||
18
homeassistant/components/device_tracker/triggers.yaml
Normal file
18
homeassistant/components/device_tracker/triggers.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
.trigger_common: &trigger_common
|
||||
target:
|
||||
entity:
|
||||
domain: device_tracker
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- first
|
||||
- last
|
||||
- any
|
||||
translation_key: trigger_behavior
|
||||
|
||||
entered_home: *trigger_common
|
||||
left_home: *trigger_common
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["ekey-bionyxpy==1.0.0"]
|
||||
"requirements": ["ekey-bionyxpy==1.0.1"]
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.trigger import Trigger, make_entity_state_trigger
|
||||
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
|
||||
|
||||
from . import DOMAIN
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"turned_off": make_entity_state_trigger(DOMAIN, STATE_OFF),
|
||||
"turned_on": make_entity_state_trigger(DOMAIN, STATE_ON),
|
||||
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
|
||||
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ ENTITY_TYPES: tuple[NumberEntityDescription, ...] = (
|
||||
NumberEntityDescription(
|
||||
key="timeToScreensaverV2",
|
||||
translation_key="screensaver_time",
|
||||
native_max_value=9999,
|
||||
native_max_value=86400,
|
||||
native_step=1,
|
||||
native_min_value=0,
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
@@ -34,7 +34,7 @@ ENTITY_TYPES: tuple[NumberEntityDescription, ...] = (
|
||||
NumberEntityDescription(
|
||||
key="timeToScreenOffV2",
|
||||
translation_key="screen_off_time",
|
||||
native_max_value=9999,
|
||||
native_max_value=86400,
|
||||
native_step=1,
|
||||
native_min_value=0,
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Any
|
||||
|
||||
import botocore.exceptions
|
||||
from homelink.auth.srp_auth import SRPAuth
|
||||
import jwt
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlowResult
|
||||
@@ -38,8 +39,6 @@ class SRPFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
"""Ask for username and password."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match({CONF_EMAIL: user_input[CONF_EMAIL]})
|
||||
|
||||
srp_auth = SRPAuth()
|
||||
try:
|
||||
tokens = await self.hass.async_add_executor_job(
|
||||
@@ -48,12 +47,17 @@ class SRPFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
user_input[CONF_PASSWORD],
|
||||
)
|
||||
except botocore.exceptions.ClientError:
|
||||
_LOGGER.exception("Error authenticating homelink account")
|
||||
errors["base"] = "srp_auth_failed"
|
||||
except Exception:
|
||||
_LOGGER.exception("An unexpected error occurred")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
access_token = jwt.decode(
|
||||
tokens["AuthenticationResult"]["AccessToken"],
|
||||
options={"verify_signature": False},
|
||||
)
|
||||
await self.async_set_unique_id(access_token["sub"])
|
||||
self._abort_if_unique_id_configured()
|
||||
self.external_data = {"tokens": tokens}
|
||||
return await self.async_step_creation()
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"""Makes requests to the state server and stores the resulting data so that the buttons can access it."""
|
||||
"""Establish MQTT connection and listen for event data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from functools import partial
|
||||
import logging
|
||||
from typing import TypedDict
|
||||
|
||||
from homelink.model.device import Device
|
||||
@@ -14,8 +13,6 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.util.ssl import get_default_context
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type HomeLinkConfigEntry = ConfigEntry[HomeLinkCoordinator]
|
||||
type EventCallback = Callable[[HomeLinkEventData], None]
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ from requests import RequestException
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_URL, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
AUTH_API_TOKEN,
|
||||
@@ -19,14 +21,25 @@ from .const import (
|
||||
DEFAULT_PLANT_ID,
|
||||
DEFAULT_URL,
|
||||
DEPRECATED_URLS,
|
||||
DOMAIN,
|
||||
LOGIN_INVALID_AUTH_CODE,
|
||||
PLATFORMS,
|
||||
)
|
||||
from .coordinator import GrowattConfigEntry, GrowattCoordinator
|
||||
from .models import GrowattRuntimeData
|
||||
from .services import async_register_services
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Growatt Server component."""
|
||||
# Register services
|
||||
await async_register_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
def get_device_list_classic(
|
||||
api: growattServer.GrowattApi, config: Mapping[str, str]
|
||||
|
||||
@@ -46,3 +46,8 @@ ERROR_INVALID_AUTH = "invalid_auth"
|
||||
|
||||
# Config flow abort reasons
|
||||
ABORT_NO_PLANTS = "no_plants"
|
||||
|
||||
# Battery modes for TOU (Time of Use) settings
|
||||
BATT_MODE_LOAD_FIRST = 0
|
||||
BATT_MODE_BATTERY_FIRST = 1
|
||||
BATT_MODE_GRID_FIRST = 2
|
||||
|
||||
@@ -12,10 +12,17 @@ import growattServer
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DEFAULT_URL, DOMAIN
|
||||
from .const import (
|
||||
BATT_MODE_BATTERY_FIRST,
|
||||
BATT_MODE_GRID_FIRST,
|
||||
BATT_MODE_LOAD_FIRST,
|
||||
DEFAULT_URL,
|
||||
DOMAIN,
|
||||
)
|
||||
from .models import GrowattRuntimeData
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -247,3 +254,134 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self.previous_values[variable] = return_value
|
||||
|
||||
return return_value
|
||||
|
||||
async def update_time_segment(
|
||||
self, segment_id: int, batt_mode: int, start_time, end_time, enabled: bool
|
||||
) -> None:
|
||||
"""Update an inverter time segment.
|
||||
|
||||
Args:
|
||||
segment_id: Time segment ID (1-9)
|
||||
batt_mode: Battery mode (0=load first, 1=battery first, 2=grid first)
|
||||
start_time: Start time (datetime.time object)
|
||||
end_time: End time (datetime.time object)
|
||||
enabled: Whether the segment is enabled
|
||||
"""
|
||||
_LOGGER.debug(
|
||||
"Updating time segment %d for device %s (mode=%d, %s-%s, enabled=%s)",
|
||||
segment_id,
|
||||
self.device_id,
|
||||
batt_mode,
|
||||
start_time,
|
||||
end_time,
|
||||
enabled,
|
||||
)
|
||||
|
||||
if self.api_version != "v1":
|
||||
raise ServiceValidationError(
|
||||
"Updating time segments requires token authentication"
|
||||
)
|
||||
|
||||
try:
|
||||
# Use V1 API for token authentication
|
||||
# The library's _process_response will raise GrowattV1ApiError if error_code != 0
|
||||
await self.hass.async_add_executor_job(
|
||||
self.api.min_write_time_segment,
|
||||
self.device_id,
|
||||
segment_id,
|
||||
batt_mode,
|
||||
start_time,
|
||||
end_time,
|
||||
enabled,
|
||||
)
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
raise HomeAssistantError(f"API error updating time segment: {err}") from err
|
||||
|
||||
# Update coordinator's cached data without making an API call (avoids rate limit)
|
||||
if self.data:
|
||||
# Update the time segment data in the cache
|
||||
self.data[f"forcedTimeStart{segment_id}"] = start_time.strftime("%H:%M")
|
||||
self.data[f"forcedTimeStop{segment_id}"] = end_time.strftime("%H:%M")
|
||||
self.data[f"time{segment_id}Mode"] = batt_mode
|
||||
self.data[f"forcedStopSwitch{segment_id}"] = 1 if enabled else 0
|
||||
|
||||
# Notify entities of the updated data (no API call)
|
||||
self.async_set_updated_data(self.data)
|
||||
|
||||
async def read_time_segments(self) -> list[dict]:
|
||||
"""Read time segments from an inverter.
|
||||
|
||||
Returns:
|
||||
List of dictionaries containing segment information
|
||||
"""
|
||||
_LOGGER.debug("Reading time segments for device %s", self.device_id)
|
||||
|
||||
if self.api_version != "v1":
|
||||
raise ServiceValidationError(
|
||||
"Reading time segments requires token authentication"
|
||||
)
|
||||
|
||||
# Ensure we have current data
|
||||
if not self.data:
|
||||
_LOGGER.debug("Coordinator data not available, triggering refresh")
|
||||
await self.async_refresh()
|
||||
|
||||
time_segments = []
|
||||
|
||||
# Extract time segments from coordinator data
|
||||
for i in range(1, 10): # Segments 1-9
|
||||
segment = self._parse_time_segment(i)
|
||||
time_segments.append(segment)
|
||||
|
||||
return time_segments
|
||||
|
||||
def _parse_time_segment(self, segment_id: int) -> dict:
|
||||
"""Parse a single time segment from coordinator data."""
|
||||
# Get raw time values - these should always be present from the API
|
||||
start_time_raw = self.data.get(f"forcedTimeStart{segment_id}")
|
||||
end_time_raw = self.data.get(f"forcedTimeStop{segment_id}")
|
||||
|
||||
# Handle 'null' or empty values from API
|
||||
if start_time_raw in ("null", None, ""):
|
||||
start_time_raw = "0:0"
|
||||
if end_time_raw in ("null", None, ""):
|
||||
end_time_raw = "0:0"
|
||||
|
||||
# Format times with leading zeros (HH:MM)
|
||||
start_time = self._format_time(str(start_time_raw))
|
||||
end_time = self._format_time(str(end_time_raw))
|
||||
|
||||
# Get battery mode
|
||||
batt_mode_int = int(
|
||||
self.data.get(f"time{segment_id}Mode", BATT_MODE_LOAD_FIRST)
|
||||
)
|
||||
|
||||
# Map numeric mode to string key (matches update_time_segment input format)
|
||||
mode_map = {
|
||||
BATT_MODE_LOAD_FIRST: "load_first",
|
||||
BATT_MODE_BATTERY_FIRST: "battery_first",
|
||||
BATT_MODE_GRID_FIRST: "grid_first",
|
||||
}
|
||||
batt_mode = mode_map.get(batt_mode_int, "load_first")
|
||||
|
||||
# Get enabled status
|
||||
enabled = bool(int(self.data.get(f"forcedStopSwitch{segment_id}", 0)))
|
||||
|
||||
return {
|
||||
"segment_id": segment_id,
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"batt_mode": batt_mode,
|
||||
"enabled": enabled,
|
||||
}
|
||||
|
||||
def _format_time(self, time_raw: str) -> str:
|
||||
"""Format time string to HH:MM format."""
|
||||
try:
|
||||
parts = str(time_raw).split(":")
|
||||
hour = int(parts[0])
|
||||
minute = int(parts[1])
|
||||
except (ValueError, IndexError):
|
||||
return "00:00"
|
||||
else:
|
||||
return f"{hour:02d}:{minute:02d}"
|
||||
|
||||
10
homeassistant/components/growatt_server/icons.json
Normal file
10
homeassistant/components/growatt_server/icons.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"services": {
|
||||
"read_time_segments": {
|
||||
"service": "mdi:clock-outline"
|
||||
},
|
||||
"update_time_segment": {
|
||||
"service": "mdi:clock-edit"
|
||||
}
|
||||
}
|
||||
}
|
||||
169
homeassistant/components/growatt_server/services.py
Normal file
169
homeassistant/components/growatt_server/services.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""Service handlers for Growatt Server integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .const import (
|
||||
BATT_MODE_BATTERY_FIRST,
|
||||
BATT_MODE_GRID_FIRST,
|
||||
BATT_MODE_LOAD_FIRST,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .coordinator import GrowattCoordinator
|
||||
|
||||
|
||||
async def async_register_services(hass: HomeAssistant) -> None:
|
||||
"""Register services for Growatt Server integration."""
|
||||
|
||||
def get_min_coordinators() -> dict[str, GrowattCoordinator]:
|
||||
"""Get all MIN coordinators with V1 API from loaded config entries."""
|
||||
min_coordinators: dict[str, GrowattCoordinator] = {}
|
||||
|
||||
for entry in hass.config_entries.async_entries(DOMAIN):
|
||||
if entry.state != ConfigEntryState.LOADED:
|
||||
continue
|
||||
|
||||
# Add MIN coordinators from this entry
|
||||
for coord in entry.runtime_data.devices.values():
|
||||
if coord.device_type == "min" and coord.api_version == "v1":
|
||||
min_coordinators[coord.device_id] = coord
|
||||
|
||||
return min_coordinators
|
||||
|
||||
def get_coordinator(device_id: str) -> GrowattCoordinator:
|
||||
"""Get coordinator by device_id.
|
||||
|
||||
Args:
|
||||
device_id: Device registry ID (not serial number)
|
||||
"""
|
||||
# Get current coordinators (they may have changed since service registration)
|
||||
min_coordinators = get_min_coordinators()
|
||||
|
||||
if not min_coordinators:
|
||||
raise ServiceValidationError(
|
||||
"No MIN devices with token authentication are configured. "
|
||||
"Services require MIN devices with V1 API access."
|
||||
)
|
||||
|
||||
# Device registry ID provided - map to serial number
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entry = device_registry.async_get(device_id)
|
||||
|
||||
if not device_entry:
|
||||
raise ServiceValidationError(f"Device '{device_id}' not found")
|
||||
|
||||
# Extract serial number from device identifiers
|
||||
serial_number = None
|
||||
for identifier in device_entry.identifiers:
|
||||
if identifier[0] == DOMAIN:
|
||||
serial_number = identifier[1]
|
||||
break
|
||||
|
||||
if not serial_number:
|
||||
raise ServiceValidationError(
|
||||
f"Device '{device_id}' is not a Growatt device"
|
||||
)
|
||||
|
||||
# Find coordinator by serial number
|
||||
if serial_number not in min_coordinators:
|
||||
raise ServiceValidationError(
|
||||
f"MIN device '{serial_number}' not found or not configured for services"
|
||||
)
|
||||
|
||||
return min_coordinators[serial_number]
|
||||
|
||||
async def handle_update_time_segment(call: ServiceCall) -> None:
|
||||
"""Handle update_time_segment service call."""
|
||||
segment_id: int = int(call.data["segment_id"])
|
||||
batt_mode_str: str = call.data["batt_mode"]
|
||||
start_time_str: str = call.data["start_time"]
|
||||
end_time_str: str = call.data["end_time"]
|
||||
enabled: bool = call.data["enabled"]
|
||||
device_id: str = call.data["device_id"]
|
||||
|
||||
# Validate segment_id range
|
||||
if not 1 <= segment_id <= 9:
|
||||
raise ServiceValidationError(
|
||||
f"segment_id must be between 1 and 9, got {segment_id}"
|
||||
)
|
||||
|
||||
# Validate and convert batt_mode string to integer
|
||||
valid_modes = {
|
||||
"load_first": BATT_MODE_LOAD_FIRST,
|
||||
"battery_first": BATT_MODE_BATTERY_FIRST,
|
||||
"grid_first": BATT_MODE_GRID_FIRST,
|
||||
}
|
||||
if batt_mode_str not in valid_modes:
|
||||
raise ServiceValidationError(
|
||||
f"batt_mode must be one of {list(valid_modes.keys())}, got '{batt_mode_str}'"
|
||||
)
|
||||
batt_mode: int = valid_modes[batt_mode_str]
|
||||
|
||||
# Convert time strings to datetime.time objects
|
||||
# UI time selector sends HH:MM:SS, but we only need HH:MM (strip seconds)
|
||||
try:
|
||||
# Take only HH:MM part (ignore seconds if present)
|
||||
start_parts = start_time_str.split(":")
|
||||
start_time_hhmm = f"{start_parts[0]}:{start_parts[1]}"
|
||||
start_time = datetime.strptime(start_time_hhmm, "%H:%M").time()
|
||||
except (ValueError, IndexError) as err:
|
||||
raise ServiceValidationError(
|
||||
"start_time must be in HH:MM or HH:MM:SS format"
|
||||
) from err
|
||||
|
||||
try:
|
||||
# Take only HH:MM part (ignore seconds if present)
|
||||
end_parts = end_time_str.split(":")
|
||||
end_time_hhmm = f"{end_parts[0]}:{end_parts[1]}"
|
||||
end_time = datetime.strptime(end_time_hhmm, "%H:%M").time()
|
||||
except (ValueError, IndexError) as err:
|
||||
raise ServiceValidationError(
|
||||
"end_time must be in HH:MM or HH:MM:SS format"
|
||||
) from err
|
||||
|
||||
# Get the appropriate MIN coordinator
|
||||
coordinator: GrowattCoordinator = get_coordinator(device_id)
|
||||
|
||||
await coordinator.update_time_segment(
|
||||
segment_id,
|
||||
batt_mode,
|
||||
start_time,
|
||||
end_time,
|
||||
enabled,
|
||||
)
|
||||
|
||||
async def handle_read_time_segments(call: ServiceCall) -> dict[str, Any]:
|
||||
"""Handle read_time_segments service call."""
|
||||
device_id: str = call.data["device_id"]
|
||||
|
||||
# Get the appropriate MIN coordinator
|
||||
coordinator: GrowattCoordinator = get_coordinator(device_id)
|
||||
|
||||
time_segments: list[dict[str, Any]] = await coordinator.read_time_segments()
|
||||
|
||||
return {"time_segments": time_segments}
|
||||
|
||||
# Register services without schema - services.yaml will provide UI definition
|
||||
# Schema validation happens in the handler functions
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"update_time_segment",
|
||||
handle_update_time_segment,
|
||||
supports_response=SupportsResponse.NONE,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"read_time_segments",
|
||||
handle_read_time_segments,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
50
homeassistant/components/growatt_server/services.yaml
Normal file
50
homeassistant/components/growatt_server/services.yaml
Normal file
@@ -0,0 +1,50 @@
|
||||
# Service definitions for Growatt Server integration
|
||||
|
||||
update_time_segment:
|
||||
fields:
|
||||
segment_id:
|
||||
required: true
|
||||
example: 1
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 9
|
||||
mode: box
|
||||
batt_mode:
|
||||
required: true
|
||||
example: "load_first"
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- "load_first"
|
||||
- "battery_first"
|
||||
- "grid_first"
|
||||
translation_key: batt_mode
|
||||
start_time:
|
||||
required: true
|
||||
example: "08:00"
|
||||
selector:
|
||||
time:
|
||||
end_time:
|
||||
required: true
|
||||
example: "12:00"
|
||||
selector:
|
||||
time:
|
||||
enabled:
|
||||
required: true
|
||||
example: true
|
||||
selector:
|
||||
boolean:
|
||||
device_id:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: growatt_server
|
||||
|
||||
read_time_segments:
|
||||
fields:
|
||||
device_id:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: growatt_server
|
||||
@@ -523,5 +523,56 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"batt_mode": {
|
||||
"options": {
|
||||
"battery_first": "Battery first",
|
||||
"grid_first": "Grid first",
|
||||
"load_first": "Load first"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"read_time_segments": {
|
||||
"description": "Read all time segments from a supported inverter.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"description": "The Growatt device to perform the action on.",
|
||||
"name": "Device"
|
||||
}
|
||||
},
|
||||
"name": "Read time segments"
|
||||
},
|
||||
"update_time_segment": {
|
||||
"description": "Update a time segment for supported inverters.",
|
||||
"fields": {
|
||||
"batt_mode": {
|
||||
"description": "Battery operation mode for this time segment.",
|
||||
"name": "Battery mode"
|
||||
},
|
||||
"device_id": {
|
||||
"description": "[%key:component::growatt_server::services::read_time_segments::fields::device_id::description%]",
|
||||
"name": "[%key:component::growatt_server::services::read_time_segments::fields::device_id::name%]"
|
||||
},
|
||||
"enabled": {
|
||||
"description": "Whether this time segment is active.",
|
||||
"name": "Enabled"
|
||||
},
|
||||
"end_time": {
|
||||
"description": "End time for the segment (HH:MM format).",
|
||||
"name": "End time"
|
||||
},
|
||||
"segment_id": {
|
||||
"description": "Time segment ID (1-9).",
|
||||
"name": "Segment ID"
|
||||
},
|
||||
"start_time": {
|
||||
"description": "Start time for the segment (HH:MM format).",
|
||||
"name": "Start time"
|
||||
}
|
||||
},
|
||||
"name": "Update time segment"
|
||||
}
|
||||
},
|
||||
"title": "Growatt Server"
|
||||
}
|
||||
|
||||
@@ -1 +1,87 @@
|
||||
"""The hikvision component."""
|
||||
"""The Hikvision integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
from pyhik.hikvision import HikCamera
|
||||
import requests
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
CONF_SSL,
|
||||
CONF_USERNAME,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [Platform.BINARY_SENSOR]
|
||||
|
||||
|
||||
@dataclass
|
||||
class HikvisionData:
|
||||
"""Data class for Hikvision runtime data."""
|
||||
|
||||
camera: HikCamera
|
||||
device_id: str
|
||||
device_name: str
|
||||
device_type: str
|
||||
|
||||
|
||||
type HikvisionConfigEntry = ConfigEntry[HikvisionData]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: HikvisionConfigEntry) -> bool:
|
||||
"""Set up Hikvision from a config entry."""
|
||||
host = entry.data[CONF_HOST]
|
||||
port = entry.data[CONF_PORT]
|
||||
username = entry.data[CONF_USERNAME]
|
||||
password = entry.data[CONF_PASSWORD]
|
||||
ssl = entry.data[CONF_SSL]
|
||||
|
||||
protocol = "https" if ssl else "http"
|
||||
url = f"{protocol}://{host}"
|
||||
|
||||
try:
|
||||
camera = await hass.async_add_executor_job(
|
||||
HikCamera, url, port, username, password
|
||||
)
|
||||
except requests.exceptions.RequestException as err:
|
||||
raise ConfigEntryNotReady(f"Unable to connect to {host}") from err
|
||||
|
||||
device_id = camera.get_id()
|
||||
if device_id is None:
|
||||
raise ConfigEntryNotReady(f"Unable to get device ID from {host}")
|
||||
|
||||
device_name = camera.get_name or host
|
||||
device_type = camera.get_type or "Camera"
|
||||
|
||||
entry.runtime_data = HikvisionData(
|
||||
camera=camera,
|
||||
device_id=device_id,
|
||||
device_name=device_name,
|
||||
device_type=device_type,
|
||||
)
|
||||
|
||||
# Start the event stream
|
||||
await hass.async_add_executor_job(camera.start_stream)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: HikvisionConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
# Stop the event stream
|
||||
await hass.async_add_executor_job(entry.runtime_data.camera.disconnect)
|
||||
|
||||
return unload_ok
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyhik.hikvision import HikCamera
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
@@ -13,6 +12,7 @@ from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_IMPORT
|
||||
from homeassistant.const import (
|
||||
ATTR_LAST_TRIP_TIME,
|
||||
CONF_CUSTOMIZE,
|
||||
@@ -23,27 +23,27 @@ from homeassistant.const import (
|
||||
CONF_PORT,
|
||||
CONF_SSL,
|
||||
CONF_USERNAME,
|
||||
EVENT_HOMEASSISTANT_START,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.event import track_point_in_utc_time
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
AddEntitiesCallback,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
from . import HikvisionConfigEntry
|
||||
from .const import DEFAULT_PORT, DOMAIN
|
||||
|
||||
CONF_IGNORED = "ignored"
|
||||
|
||||
DEFAULT_PORT = 80
|
||||
DEFAULT_IGNORED = False
|
||||
DEFAULT_DELAY = 0
|
||||
DEFAULT_IGNORED = False
|
||||
|
||||
ATTR_DELAY = "delay"
|
||||
|
||||
DEVICE_CLASS_MAP = {
|
||||
# Device class mapping for Hikvision event types
|
||||
DEVICE_CLASS_MAP: dict[str, BinarySensorDeviceClass | None] = {
|
||||
"Motion": BinarySensorDeviceClass.MOTION,
|
||||
"Line Crossing": BinarySensorDeviceClass.MOTION,
|
||||
"Field Detection": BinarySensorDeviceClass.MOTION,
|
||||
@@ -67,6 +67,8 @@ DEVICE_CLASS_MAP = {
|
||||
"Entering Region": BinarySensorDeviceClass.MOTION,
|
||||
}
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CUSTOMIZE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_IGNORED, default=DEFAULT_IGNORED): cv.boolean,
|
||||
@@ -88,214 +90,144 @@ PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend(
|
||||
}
|
||||
)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
def setup_platform(
|
||||
|
||||
async def async_setup_platform(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
add_entities: AddEntitiesCallback,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the Hikvision binary sensor devices."""
|
||||
name = config.get(CONF_NAME)
|
||||
host = config[CONF_HOST]
|
||||
port = config[CONF_PORT]
|
||||
username = config[CONF_USERNAME]
|
||||
password = config[CONF_PASSWORD]
|
||||
"""Set up the Hikvision binary sensor platform from YAML."""
|
||||
# Trigger the import flow to migrate YAML config to config entry
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_IMPORT}, data=config
|
||||
)
|
||||
|
||||
customize = config[CONF_CUSTOMIZE]
|
||||
|
||||
protocol = "https" if config[CONF_SSL] else "http"
|
||||
|
||||
url = f"{protocol}://{host}"
|
||||
|
||||
data = HikvisionData(hass, url, port, name, username, password)
|
||||
|
||||
if data.sensors is None:
|
||||
_LOGGER.error("Hikvision event stream has no data, unable to set up")
|
||||
if (
|
||||
result.get("type") is FlowResultType.ABORT
|
||||
and result.get("reason") != "already_configured"
|
||||
):
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"deprecated_yaml_import_issue_{result.get('reason')}",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_yaml_import_issue",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"integration_title": "Hikvision",
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
entities = []
|
||||
|
||||
for sensor, channel_list in data.sensors.items():
|
||||
for channel in channel_list:
|
||||
# Build sensor name, then parse customize config.
|
||||
if data.type == "NVR":
|
||||
sensor_name = f"{sensor.replace(' ', '_')}_{channel[1]}"
|
||||
else:
|
||||
sensor_name = sensor.replace(" ", "_")
|
||||
|
||||
custom = customize.get(sensor_name.lower(), {})
|
||||
ignore = custom.get(CONF_IGNORED)
|
||||
delay = custom.get(CONF_DELAY)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Entity: %s - %s, Options - Ignore: %s, Delay: %s",
|
||||
data.name,
|
||||
sensor_name,
|
||||
ignore,
|
||||
delay,
|
||||
)
|
||||
if not ignore:
|
||||
entities.append(
|
||||
HikvisionBinarySensor(hass, sensor, channel[1], data, delay)
|
||||
)
|
||||
|
||||
add_entities(entities)
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
HOMEASSISTANT_DOMAIN,
|
||||
f"deprecated_yaml_{DOMAIN}",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_yaml",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"integration_title": "Hikvision",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class HikvisionData:
|
||||
"""Hikvision device event stream object."""
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HikvisionConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Hikvision binary sensors from a config entry."""
|
||||
data = entry.runtime_data
|
||||
camera = data.camera
|
||||
|
||||
def __init__(self, hass, url, port, name, username, password):
|
||||
"""Initialize the data object."""
|
||||
self._url = url
|
||||
self._port = port
|
||||
self._name = name
|
||||
self._username = username
|
||||
self._password = password
|
||||
sensors = camera.current_event_states
|
||||
if sensors is None or not sensors:
|
||||
_LOGGER.warning("Hikvision device has no sensors available")
|
||||
return
|
||||
|
||||
# Establish camera
|
||||
self.camdata = HikCamera(self._url, self._port, self._username, self._password)
|
||||
|
||||
if self._name is None:
|
||||
self._name = self.camdata.get_name
|
||||
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, self.stop_hik)
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_START, self.start_hik)
|
||||
|
||||
def stop_hik(self, event):
|
||||
"""Shutdown Hikvision subscriptions and subscription thread on exit."""
|
||||
self.camdata.disconnect()
|
||||
|
||||
def start_hik(self, event):
|
||||
"""Start Hikvision event stream thread."""
|
||||
self.camdata.start_stream()
|
||||
|
||||
@property
|
||||
def sensors(self):
|
||||
"""Return list of available sensors and their states."""
|
||||
return self.camdata.current_event_states
|
||||
|
||||
@property
|
||||
def cam_id(self):
|
||||
"""Return device id."""
|
||||
return self.camdata.get_id
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return device name."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def type(self):
|
||||
"""Return device type."""
|
||||
return self.camdata.get_type
|
||||
|
||||
def get_attributes(self, sensor, channel):
|
||||
"""Return attribute list for sensor/channel."""
|
||||
return self.camdata.fetch_attributes(sensor, channel)
|
||||
async_add_entities(
|
||||
HikvisionBinarySensor(
|
||||
entry=entry,
|
||||
sensor_type=sensor_type,
|
||||
channel=channel_info[1],
|
||||
)
|
||||
for sensor_type, channel_list in sensors.items()
|
||||
for channel_info in channel_list
|
||||
)
|
||||
|
||||
|
||||
class HikvisionBinarySensor(BinarySensorEntity):
|
||||
"""Representation of a Hikvision binary sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, hass, sensor, channel, cam, delay):
|
||||
"""Initialize the binary_sensor."""
|
||||
self._hass = hass
|
||||
self._cam = cam
|
||||
self._sensor = sensor
|
||||
def __init__(
|
||||
self,
|
||||
entry: HikvisionConfigEntry,
|
||||
sensor_type: str,
|
||||
channel: int,
|
||||
) -> None:
|
||||
"""Initialize the binary sensor."""
|
||||
self._data = entry.runtime_data
|
||||
self._camera = self._data.camera
|
||||
self._sensor_type = sensor_type
|
||||
self._channel = channel
|
||||
|
||||
if self._cam.type == "NVR":
|
||||
self._name = f"{self._cam.name} {sensor} {channel}"
|
||||
# Build unique ID
|
||||
self._attr_unique_id = f"{self._data.device_id}_{sensor_type}_{channel}"
|
||||
|
||||
# Build entity name based on device type
|
||||
if self._data.device_type == "NVR":
|
||||
self._attr_name = f"{sensor_type} {channel}"
|
||||
else:
|
||||
self._name = f"{self._cam.name} {sensor}"
|
||||
self._attr_name = sensor_type
|
||||
|
||||
self._id = f"{self._cam.cam_id}.{sensor}.{channel}"
|
||||
# Device info for device registry
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._data.device_id)},
|
||||
name=self._data.device_name,
|
||||
manufacturer="Hikvision",
|
||||
model=self._data.device_type,
|
||||
)
|
||||
|
||||
if delay is None:
|
||||
self._delay = 0
|
||||
else:
|
||||
self._delay = delay
|
||||
# Set device class
|
||||
self._attr_device_class = DEVICE_CLASS_MAP.get(sensor_type)
|
||||
|
||||
self._timer = None
|
||||
# Callback ID for pyhik
|
||||
self._callback_id = f"{self._data.device_id}.{sensor_type}.{channel}"
|
||||
|
||||
# Register callback function with pyHik
|
||||
self._cam.camdata.add_update_callback(self._update_callback, self._id)
|
||||
|
||||
def _sensor_state(self):
|
||||
"""Extract sensor state."""
|
||||
return self._cam.get_attributes(self._sensor, self._channel)[0]
|
||||
|
||||
def _sensor_last_update(self):
|
||||
"""Extract sensor last update time."""
|
||||
return self._cam.get_attributes(self._sensor, self._channel)[3]
|
||||
def _get_sensor_attributes(self) -> tuple[bool, Any, Any, Any]:
|
||||
"""Get sensor attributes from camera."""
|
||||
return self._camera.fetch_attributes(self._sensor_type, self._channel)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the Hikvision sensor."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return a unique ID."""
|
||||
return self._id
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if sensor is on."""
|
||||
return self._sensor_state()
|
||||
return self._get_sensor_attributes()[0]
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the class of this sensor, from DEVICE_CLASSES."""
|
||||
try:
|
||||
return DEVICE_CLASS_MAP[self._sensor]
|
||||
except KeyError:
|
||||
# Sensor must be unknown to us, add as generic
|
||||
return None
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the state attributes."""
|
||||
attr = {ATTR_LAST_TRIP_TIME: self._sensor_last_update()}
|
||||
attrs = self._get_sensor_attributes()
|
||||
return {ATTR_LAST_TRIP_TIME: attrs[3]}
|
||||
|
||||
if self._delay != 0:
|
||||
attr[ATTR_DELAY] = self._delay
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callback when entity is added."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
return attr
|
||||
# Register callback with pyhik
|
||||
self._camera.add_update_callback(self._update_callback, self._callback_id)
|
||||
|
||||
def _update_callback(self, msg):
|
||||
"""Update the sensor's state, if needed."""
|
||||
_LOGGER.debug("Callback signal from: %s", msg)
|
||||
|
||||
if self._delay > 0 and not self.is_on:
|
||||
# Set timer to wait until updating the state
|
||||
def _delay_update(now):
|
||||
"""Timer callback for sensor update."""
|
||||
_LOGGER.debug(
|
||||
"%s Called delayed (%ssec) update", self._name, self._delay
|
||||
)
|
||||
self.schedule_update_ha_state()
|
||||
self._timer = None
|
||||
|
||||
if self._timer is not None:
|
||||
self._timer()
|
||||
self._timer = None
|
||||
|
||||
self._timer = track_point_in_utc_time(
|
||||
self._hass, _delay_update, utcnow() + timedelta(seconds=self._delay)
|
||||
)
|
||||
|
||||
elif self._delay > 0 and self.is_on:
|
||||
# For delayed sensors kill any callbacks on true events and update
|
||||
if self._timer is not None:
|
||||
self._timer()
|
||||
self._timer = None
|
||||
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
else:
|
||||
self.schedule_update_ha_state()
|
||||
@callback
|
||||
def _update_callback(self, msg: str) -> None:
|
||||
"""Update the sensor's state when callback is triggered."""
|
||||
self.async_write_ha_state()
|
||||
|
||||
134
homeassistant/components/hikvision/config_flow.py
Normal file
134
homeassistant/components/hikvision/config_flow.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Config flow for Hikvision integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyhik.hikvision import HikCamera
|
||||
import requests
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
CONF_SSL,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DEFAULT_PORT, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HikvisionConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Hikvision."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
host = user_input[CONF_HOST]
|
||||
port = user_input[CONF_PORT]
|
||||
username = user_input[CONF_USERNAME]
|
||||
password = user_input[CONF_PASSWORD]
|
||||
ssl = user_input[CONF_SSL]
|
||||
|
||||
protocol = "https" if ssl else "http"
|
||||
url = f"{protocol}://{host}"
|
||||
|
||||
try:
|
||||
camera = await self.hass.async_add_executor_job(
|
||||
HikCamera, url, port, username, password
|
||||
)
|
||||
device_id = camera.get_id()
|
||||
device_name = camera.get_name
|
||||
except requests.exceptions.RequestException:
|
||||
_LOGGER.exception("Error connecting to Hikvision device")
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
if device_id is None:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
await self.async_set_unique_id(device_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(
|
||||
title=device_name or host,
|
||||
data={
|
||||
CONF_HOST: host,
|
||||
CONF_PORT: port,
|
||||
CONF_USERNAME: username,
|
||||
CONF_PASSWORD: password,
|
||||
CONF_SSL: ssl,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): str,
|
||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
vol.Required(CONF_SSL, default=False): bool,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_import(self, import_data: ConfigType) -> ConfigFlowResult:
|
||||
"""Handle import from configuration.yaml."""
|
||||
host = import_data[CONF_HOST]
|
||||
port = import_data.get(CONF_PORT, DEFAULT_PORT)
|
||||
username = import_data[CONF_USERNAME]
|
||||
password = import_data[CONF_PASSWORD]
|
||||
ssl = import_data.get(CONF_SSL, False)
|
||||
name = import_data.get(CONF_NAME)
|
||||
|
||||
protocol = "https" if ssl else "http"
|
||||
url = f"{protocol}://{host}"
|
||||
|
||||
try:
|
||||
camera = await self.hass.async_add_executor_job(
|
||||
HikCamera, url, port, username, password
|
||||
)
|
||||
device_id = camera.get_id()
|
||||
device_name = camera.get_name
|
||||
except requests.exceptions.RequestException:
|
||||
_LOGGER.exception(
|
||||
"Error connecting to Hikvision device during import, aborting"
|
||||
)
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
if device_id is None:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
await self.async_set_unique_id(device_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
_LOGGER.warning(
|
||||
"Importing Hikvision config from configuration.yaml for %s", host
|
||||
)
|
||||
|
||||
return self.async_create_entry(
|
||||
title=name or device_name or host,
|
||||
data={
|
||||
CONF_HOST: host,
|
||||
CONF_PORT: port,
|
||||
CONF_USERNAME: username,
|
||||
CONF_PASSWORD: password,
|
||||
CONF_SSL: ssl,
|
||||
},
|
||||
)
|
||||
6
homeassistant/components/hikvision/const.py
Normal file
6
homeassistant/components/hikvision/const.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Constants for the Hikvision integration."""
|
||||
|
||||
DOMAIN = "hikvision"
|
||||
|
||||
# Default values
|
||||
DEFAULT_PORT = 80
|
||||
@@ -2,7 +2,9 @@
|
||||
"domain": "hikvision",
|
||||
"name": "Hikvision",
|
||||
"codeowners": ["@mezz64"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/hikvision",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyhik"],
|
||||
"quality_scale": "legacy",
|
||||
|
||||
36
homeassistant/components/hikvision/strings.json
Normal file
36
homeassistant/components/hikvision/strings.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"ssl": "Use SSL",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your Hikvision device",
|
||||
"password": "The password for your Hikvision device",
|
||||
"port": "The port number for the device (default is 80)",
|
||||
"ssl": "Enable if your device uses HTTPS",
|
||||
"username": "The username for your Hikvision device"
|
||||
},
|
||||
"description": "Enter your Hikvision device connection details.",
|
||||
"title": "Set up Hikvision device"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml_import_issue": {
|
||||
"description": "Configuring {integration_title} using YAML is deprecated and the import failed. Please remove the `{domain}` entry from your `configuration.yaml` file and set up the integration manually.",
|
||||
"title": "YAML import failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
"homekit": {
|
||||
"models": ["HHKBridge*"]
|
||||
},
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["apyhiveapi"],
|
||||
"requirements": ["pyhive-integration==1.0.7"]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@MisterCommand"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/hko",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["hko==0.3.2"]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@jameshilliard"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/hlk_sw16",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["hlk_sw16"],
|
||||
"requirements": ["hlk-sw16==0.0.9"]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@hahn-th"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/homematicip_cloud",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["homematicip"],
|
||||
"requirements": ["homematicip==2.4.0"]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": [],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/homeworks",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyhomeworks"],
|
||||
"requirements": ["pyhomeworks==1.1.2"]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@rdfurman", "@mkmer"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/honeywell",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["somecomfort"],
|
||||
"requirements": ["AIOSomecomfort==0.0.35"]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@scop", "@fphammerle"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/huawei_lte",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["huawei_lte_api.Session"],
|
||||
"requirements": ["huawei-lte-api==1.11.0", "url-normalize==2.2.1"],
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"homekit": {
|
||||
"models": ["PowerView"]
|
||||
},
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiopvapi"],
|
||||
"requirements": ["aiopvapi==3.3.0"],
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==1.6.0"]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@frwickst", "@vincentwolsink"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/huum",
|
||||
"integration_type": "device",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["huum==0.8.1"]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@vigonotion"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/hvv_departures",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pygti"],
|
||||
"requirements": ["pygti==0.9.4"]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@dknowles2", "@thomaskistler", "@ptcryan"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/hydrawise",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pydrawise"],
|
||||
"requirements": ["pydrawise==2025.9.0"]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@RyuzakiKK"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/ialarm",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyialarm"],
|
||||
"requirements": ["pyialarm==2.2.0"]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@flz"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/iaqualink",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["iaqualink"],
|
||||
"requirements": ["iaqualink==0.6.0", "h2==4.3.0"],
|
||||
|
||||
@@ -108,7 +108,7 @@ class IcloudAccount:
|
||||
|
||||
if self.api.requires_2fa:
|
||||
# Trigger a new log in to ensure the user enters the 2FA code again.
|
||||
raise PyiCloudFailedLoginException # noqa: TRY301
|
||||
raise PyiCloudFailedLoginException("2FA Required") # noqa: TRY301
|
||||
|
||||
except PyiCloudFailedLoginException:
|
||||
self.api = None
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@Quentame", "@nzapponi"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/icloud",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["keyrings.alt", "pyicloud"],
|
||||
"requirements": ["pyicloud==2.2.0"]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@keithle888"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/igloohome",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["igloohome-api==0.1.1"]
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["repairs"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/imap",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aioimaplib"],
|
||||
"requirements": ["aioimaplib==2.0.1"]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@bieniu"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/imgw_pib",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["imgw_pib==1.6.0"]
|
||||
|
||||
@@ -261,7 +261,8 @@ class ImprovBLEConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if self._can_identify is None:
|
||||
try:
|
||||
self._can_identify = await self._try_call(device.can_identify())
|
||||
await self._try_call(device.ensure_connected())
|
||||
self._can_identify = device.can_identify
|
||||
except AbortFlow as err:
|
||||
return self.async_abort(reason=err.reason)
|
||||
if self._can_identify:
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/improv_ble",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["py-improv-ble-client==1.0.3"]
|
||||
"requirements": ["py-improv-ble-client==2.0.1"]
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["mqtt"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/inels",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"mqtt": ["inels/status/#"],
|
||||
"quality_scale": "bronze",
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/inkbird",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["inkbird-ble==1.1.1"]
|
||||
}
|
||||
|
||||
@@ -14,10 +14,11 @@
|
||||
}
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/insteon",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyinsteon", "pypubsub"],
|
||||
"requirements": [
|
||||
"pyinsteon==1.6.3",
|
||||
"pyinsteon==1.6.4",
|
||||
"insteon-frontend-home-assistant==0.5.0"
|
||||
],
|
||||
"single_config_entry": true,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
}
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/intellifire",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["intellifire4py"],
|
||||
"requirements": ["intellifire4py==4.2.1"]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@gtdiehl", "@jyavenard"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/iotawatt",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["iotawattpy"],
|
||||
"requirements": ["ha-iotawattpy==0.1.2"]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@dgomes"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/ipma",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["geopy", "pyipma"],
|
||||
"requirements": ["pyipma==3.0.9"]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@engrbm87", "@cpfair"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/islamic_prayer_times",
|
||||
"integration_type": "service",
|
||||
"iot_class": "calculated",
|
||||
"loggers": ["prayer_times_calculator"],
|
||||
"requirements": ["prayer-times-calculator-offline==1.0.3"]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@shaiu"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/israel_rail",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["israelrailapi"],
|
||||
"requirements": ["israel-rail-api==0.1.4"]
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"codeowners": ["@tr4nt0r"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/ista_ecotrend",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyecotrend_ista"],
|
||||
"quality_scale": "gold",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"homekit": {
|
||||
"models": ["iZone"]
|
||||
},
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pizone"],
|
||||
"requirements": ["python-izone==1.2.9"]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@kvanzuijlen"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/justnimbus",
|
||||
"integration_type": "device",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["justnimbus==0.7.4"]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@SteveEasley"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/kaleidescape",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["pykaleidescape==1.0.2"],
|
||||
"ssdp": [
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@foxel"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/keenetic_ndms2",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["ndms2_client"],
|
||||
"requirements": ["ndms2-client==0.1.2"],
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/kegtron",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["kegtron-ble==1.0.2"]
|
||||
}
|
||||
|
||||
@@ -163,7 +163,6 @@ SUPPORTED_PLATFORMS_UI: Final = {
|
||||
Platform.CLIMATE,
|
||||
Platform.COVER,
|
||||
Platform.DATE,
|
||||
Platform.FAN,
|
||||
Platform.DATETIME,
|
||||
Platform.LIGHT,
|
||||
Platform.SWITCH,
|
||||
@@ -218,9 +217,3 @@ class ClimateConf:
|
||||
FAN_MAX_STEP: Final = "fan_max_step"
|
||||
FAN_SPEED_MODE: Final = "fan_speed_mode"
|
||||
FAN_ZERO_MODE: Final = "fan_zero_mode"
|
||||
|
||||
|
||||
class FanConf:
|
||||
"""Common config keys for fan."""
|
||||
|
||||
MAX_STEP: Final = "max_step"
|
||||
|
||||
@@ -5,17 +5,13 @@ from __future__ import annotations
|
||||
import math
|
||||
from typing import Any, Final
|
||||
|
||||
from propcache.api import cached_property
|
||||
from xknx.devices import Fan as XknxFan
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.fan import FanEntity, FanEntityFeature
|
||||
from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
async_get_current_platform,
|
||||
)
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.percentage import (
|
||||
percentage_to_ranged_value,
|
||||
@@ -23,18 +19,10 @@ from homeassistant.util.percentage import (
|
||||
)
|
||||
from homeassistant.util.scaling import int_states_in_range
|
||||
|
||||
from .const import CONF_SYNC_STATE, DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY, FanConf
|
||||
from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity
|
||||
from .const import KNX_ADDRESS, KNX_MODULE_KEY
|
||||
from .entity import KnxYamlEntity
|
||||
from .knx_module import KNXModule
|
||||
from .schema import FanSchema
|
||||
from .storage.const import (
|
||||
CONF_ENTITY,
|
||||
CONF_GA_OSCILLATION,
|
||||
CONF_GA_SPEED,
|
||||
CONF_GA_STEP,
|
||||
CONF_SPEED,
|
||||
)
|
||||
from .storage.util import ConfigExtractor
|
||||
|
||||
DEFAULT_PERCENTAGE: Final = 50
|
||||
|
||||
@@ -46,36 +34,40 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up fan(s) for KNX platform."""
|
||||
knx_module = hass.data[KNX_MODULE_KEY]
|
||||
platform = async_get_current_platform()
|
||||
knx_module.config_store.add_platform(
|
||||
platform=Platform.FAN,
|
||||
controller=KnxUiEntityPlatformController(
|
||||
knx_module=knx_module,
|
||||
entity_platform=platform,
|
||||
entity_class=KnxUiFan,
|
||||
),
|
||||
)
|
||||
config: list[ConfigType] = knx_module.config_yaml[Platform.FAN]
|
||||
|
||||
entities: list[_KnxFan] = []
|
||||
if yaml_platform_config := knx_module.config_yaml.get(Platform.FAN):
|
||||
entities.extend(
|
||||
KnxYamlFan(knx_module, entity_config)
|
||||
for entity_config in yaml_platform_config
|
||||
)
|
||||
if ui_config := knx_module.config_store.data["entities"].get(Platform.FAN):
|
||||
entities.extend(
|
||||
KnxUiFan(knx_module, unique_id, config)
|
||||
for unique_id, config in ui_config.items()
|
||||
)
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
async_add_entities(KNXFan(knx_module, entity_config) for entity_config in config)
|
||||
|
||||
|
||||
class _KnxFan(FanEntity):
|
||||
class KNXFan(KnxYamlEntity, FanEntity):
|
||||
"""Representation of a KNX fan."""
|
||||
|
||||
_device: XknxFan
|
||||
_step_range: tuple[int, int] | None
|
||||
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize of KNX fan."""
|
||||
max_step = config.get(FanSchema.CONF_MAX_STEP)
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=XknxFan(
|
||||
xknx=knx_module.xknx,
|
||||
name=config[CONF_NAME],
|
||||
group_address_speed=config.get(KNX_ADDRESS),
|
||||
group_address_speed_state=config.get(FanSchema.CONF_STATE_ADDRESS),
|
||||
group_address_oscillation=config.get(
|
||||
FanSchema.CONF_OSCILLATION_ADDRESS
|
||||
),
|
||||
group_address_oscillation_state=config.get(
|
||||
FanSchema.CONF_OSCILLATION_STATE_ADDRESS
|
||||
),
|
||||
max_step=max_step,
|
||||
),
|
||||
)
|
||||
# FanSpeedMode.STEP if max_step is set
|
||||
self._step_range: tuple[int, int] | None = (1, max_step) if max_step else None
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
|
||||
self._attr_unique_id = str(self._device.speed.group_address)
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Set the speed of the fan, as a percentage."""
|
||||
@@ -85,7 +77,7 @@ class _KnxFan(FanEntity):
|
||||
else:
|
||||
await self._device.set_speed(percentage)
|
||||
|
||||
@cached_property
|
||||
@property
|
||||
def supported_features(self) -> FanEntityFeature:
|
||||
"""Flag supported features."""
|
||||
flags = (
|
||||
@@ -111,7 +103,7 @@ class _KnxFan(FanEntity):
|
||||
)
|
||||
return self._device.current_speed
|
||||
|
||||
@cached_property
|
||||
@property
|
||||
def speed_count(self) -> int:
|
||||
"""Return the number of speeds the fan supports."""
|
||||
if self._step_range is None:
|
||||
@@ -142,76 +134,3 @@ class _KnxFan(FanEntity):
|
||||
def oscillating(self) -> bool | None:
|
||||
"""Return whether or not the fan is currently oscillating."""
|
||||
return self._device.current_oscillation
|
||||
|
||||
|
||||
class KnxYamlFan(_KnxFan, KnxYamlEntity):
|
||||
"""Representation of a KNX fan configured from YAML."""
|
||||
|
||||
_device: XknxFan
|
||||
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize of KNX fan."""
|
||||
max_step = config.get(FanConf.MAX_STEP)
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=XknxFan(
|
||||
xknx=knx_module.xknx,
|
||||
name=config[CONF_NAME],
|
||||
group_address_speed=config.get(KNX_ADDRESS),
|
||||
group_address_speed_state=config.get(FanSchema.CONF_STATE_ADDRESS),
|
||||
group_address_oscillation=config.get(
|
||||
FanSchema.CONF_OSCILLATION_ADDRESS
|
||||
),
|
||||
group_address_oscillation_state=config.get(
|
||||
FanSchema.CONF_OSCILLATION_STATE_ADDRESS
|
||||
),
|
||||
max_step=max_step,
|
||||
),
|
||||
)
|
||||
# FanSpeedMode.STEP if max_step is set
|
||||
self._step_range: tuple[int, int] | None = (1, max_step) if max_step else None
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
|
||||
self._attr_unique_id = str(self._device.speed.group_address)
|
||||
|
||||
|
||||
class KnxUiFan(_KnxFan, KnxUiEntity):
|
||||
"""Representation of a KNX fan configured from UI."""
|
||||
|
||||
_device: XknxFan
|
||||
|
||||
def __init__(
|
||||
self, knx_module: KNXModule, unique_id: str, config: dict[str, Any]
|
||||
) -> None:
|
||||
"""Initialize of KNX fan."""
|
||||
knx_conf = ConfigExtractor(config[DOMAIN])
|
||||
# max_step is required for step mode, thus can be used to differentiate modes
|
||||
max_step: int | None = knx_conf.get(CONF_SPEED, FanConf.MAX_STEP)
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
unique_id=unique_id,
|
||||
entity_config=config[CONF_ENTITY],
|
||||
)
|
||||
if max_step:
|
||||
# step control
|
||||
speed_write = knx_conf.get_write(CONF_SPEED, CONF_GA_STEP)
|
||||
speed_state = knx_conf.get_state_and_passive(CONF_SPEED, CONF_GA_STEP)
|
||||
else:
|
||||
# percentage control
|
||||
speed_write = knx_conf.get_write(CONF_SPEED, CONF_GA_SPEED)
|
||||
speed_state = knx_conf.get_state_and_passive(CONF_SPEED, CONF_GA_SPEED)
|
||||
|
||||
self._device = XknxFan(
|
||||
xknx=knx_module.xknx,
|
||||
name=config[CONF_ENTITY][CONF_NAME],
|
||||
group_address_speed=speed_write,
|
||||
group_address_speed_state=speed_state,
|
||||
group_address_oscillation=knx_conf.get_write(CONF_GA_OSCILLATION),
|
||||
group_address_oscillation_state=knx_conf.get_state_and_passive(
|
||||
CONF_GA_OSCILLATION
|
||||
),
|
||||
max_step=max_step,
|
||||
sync_state=knx_conf.get(CONF_SYNC_STATE),
|
||||
)
|
||||
# FanSpeedMode.STEP if max_step is set
|
||||
self._step_range: tuple[int, int] | None = (1, max_step) if max_step else None
|
||||
|
||||
@@ -59,7 +59,6 @@ from .const import (
|
||||
ClimateConf,
|
||||
ColorTempModes,
|
||||
CoverConf,
|
||||
FanConf,
|
||||
FanZeroMode,
|
||||
)
|
||||
from .validation import (
|
||||
@@ -576,6 +575,7 @@ class FanSchema(KNXPlatformSchema):
|
||||
CONF_STATE_ADDRESS = CONF_STATE_ADDRESS
|
||||
CONF_OSCILLATION_ADDRESS = "oscillation_address"
|
||||
CONF_OSCILLATION_STATE_ADDRESS = "oscillation_state_address"
|
||||
CONF_MAX_STEP = "max_step"
|
||||
|
||||
DEFAULT_NAME = "KNX Fan"
|
||||
|
||||
@@ -586,7 +586,7 @@ class FanSchema(KNXPlatformSchema):
|
||||
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
|
||||
vol.Optional(CONF_OSCILLATION_ADDRESS): ga_list_validator,
|
||||
vol.Optional(CONF_OSCILLATION_STATE_ADDRESS): ga_list_validator,
|
||||
vol.Optional(FanConf.MAX_STEP): cv.byte,
|
||||
vol.Optional(CONF_MAX_STEP): cv.byte,
|
||||
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -17,8 +17,6 @@ CONF_GA_DATE: Final = "ga_date"
|
||||
CONF_GA_DATETIME: Final = "ga_datetime"
|
||||
CONF_GA_TIME: Final = "ga_time"
|
||||
|
||||
CONF_GA_STEP: Final = "ga_step"
|
||||
|
||||
# Climate
|
||||
CONF_GA_TEMPERATURE_CURRENT: Final = "ga_temperature_current"
|
||||
CONF_GA_HUMIDITY_CURRENT: Final = "ga_humidity_current"
|
||||
@@ -44,15 +42,11 @@ CONF_GA_FAN_SWING_HORIZONTAL: Final = "ga_fan_swing_horizontal"
|
||||
# Cover
|
||||
CONF_GA_UP_DOWN: Final = "ga_up_down"
|
||||
CONF_GA_STOP: Final = "ga_stop"
|
||||
CONF_GA_STEP: Final = "ga_step"
|
||||
CONF_GA_POSITION_SET: Final = "ga_position_set"
|
||||
CONF_GA_POSITION_STATE: Final = "ga_position_state"
|
||||
CONF_GA_ANGLE: Final = "ga_angle"
|
||||
|
||||
# Fan
|
||||
CONF_SPEED: Final = "speed"
|
||||
CONF_GA_SPEED: Final = "ga_speed"
|
||||
CONF_GA_OSCILLATION: Final = "ga_oscillation"
|
||||
|
||||
# Light
|
||||
CONF_COLOR_TEMP_MIN: Final = "color_temp_min"
|
||||
CONF_COLOR_TEMP_MAX: Final = "color_temp_max"
|
||||
|
||||
@@ -28,7 +28,6 @@ from ..const import (
|
||||
ClimateConf,
|
||||
ColorTempModes,
|
||||
CoverConf,
|
||||
FanConf,
|
||||
FanZeroMode,
|
||||
)
|
||||
from .const import (
|
||||
@@ -63,7 +62,6 @@ from .const import (
|
||||
CONF_GA_OP_MODE_PROTECTION,
|
||||
CONF_GA_OP_MODE_STANDBY,
|
||||
CONF_GA_OPERATION_MODE,
|
||||
CONF_GA_OSCILLATION,
|
||||
CONF_GA_POSITION_SET,
|
||||
CONF_GA_POSITION_STATE,
|
||||
CONF_GA_RED_BRIGHTNESS,
|
||||
@@ -71,7 +69,6 @@ from .const import (
|
||||
CONF_GA_SATURATION,
|
||||
CONF_GA_SENSOR,
|
||||
CONF_GA_SETPOINT_SHIFT,
|
||||
CONF_GA_SPEED,
|
||||
CONF_GA_STEP,
|
||||
CONF_GA_STOP,
|
||||
CONF_GA_SWITCH,
|
||||
@@ -83,7 +80,6 @@ from .const import (
|
||||
CONF_GA_WHITE_BRIGHTNESS,
|
||||
CONF_GA_WHITE_SWITCH,
|
||||
CONF_IGNORE_AUTO_MODE,
|
||||
CONF_SPEED,
|
||||
CONF_TARGET_TEMPERATURE,
|
||||
)
|
||||
from .knx_selector import (
|
||||
@@ -224,42 +220,6 @@ DATETIME_KNX_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
FAN_KNX_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_SPEED): GroupSelect(
|
||||
GroupSelectOption(
|
||||
translation_key="percentage_mode",
|
||||
schema={
|
||||
vol.Required(CONF_GA_SPEED): GASelector(
|
||||
write_required=True, valid_dpt="5.001"
|
||||
),
|
||||
},
|
||||
),
|
||||
GroupSelectOption(
|
||||
translation_key="step_mode",
|
||||
schema={
|
||||
vol.Required(CONF_GA_STEP): GASelector(
|
||||
write_required=True, valid_dpt="5.010"
|
||||
),
|
||||
vol.Required(FanConf.MAX_STEP, default=3): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=1,
|
||||
max=100,
|
||||
step=1,
|
||||
mode=selector.NumberSelectorMode.BOX,
|
||||
)
|
||||
),
|
||||
},
|
||||
),
|
||||
collapsible=False,
|
||||
),
|
||||
vol.Optional(CONF_GA_OSCILLATION): GASelector(
|
||||
write_required=True, valid_dpt="1"
|
||||
),
|
||||
vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@unique
|
||||
class LightColorMode(StrEnum):
|
||||
@@ -553,7 +513,6 @@ KNX_SCHEMA_FOR_PLATFORM = {
|
||||
Platform.COVER: COVER_KNX_SCHEMA,
|
||||
Platform.DATE: DATE_KNX_SCHEMA,
|
||||
Platform.DATETIME: DATETIME_KNX_SCHEMA,
|
||||
Platform.FAN: FAN_KNX_SCHEMA,
|
||||
Platform.LIGHT: LIGHT_KNX_SCHEMA,
|
||||
Platform.SWITCH: SWITCH_KNX_SCHEMA,
|
||||
Platform.TIME: TIME_KNX_SCHEMA,
|
||||
|
||||
@@ -460,41 +460,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"fan": {
|
||||
"description": "The KNX fan platform is used as an interface to fan actuators.",
|
||||
"knx": {
|
||||
"ga_oscillation": {
|
||||
"description": "Toggle oscillation of the fan.",
|
||||
"label": "Oscillation"
|
||||
},
|
||||
"speed": {
|
||||
"description": "Control the speed of the fan.",
|
||||
"ga_speed": {
|
||||
"description": "Group address to control the current speed of the fan as a percentage value.",
|
||||
"label": "Speed"
|
||||
},
|
||||
"ga_step": {
|
||||
"description": "Group address to control the current speed step.",
|
||||
"label": "Step"
|
||||
},
|
||||
"max_step": {
|
||||
"description": "Number of discrete fan speed steps (Off excluded).",
|
||||
"label": "Fan steps"
|
||||
},
|
||||
"options": {
|
||||
"percentage_mode": {
|
||||
"description": "Set the fan speed as a percentage value (0-100%).",
|
||||
"label": "Percentage"
|
||||
},
|
||||
"step_mode": {
|
||||
"description": "Set the fan speed in discrete steps.",
|
||||
"label": "Steps"
|
||||
}
|
||||
},
|
||||
"title": "Fan speed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"header": "Create new entity",
|
||||
"light": {
|
||||
"description": "The KNX light platform is used as an interface to dimming actuators, LED controllers, DALI gateways and similar.",
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
"""Provides triggers for lawn mowers."""
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.trigger import Trigger, make_entity_state_trigger
|
||||
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
|
||||
|
||||
from .const import DOMAIN, LawnMowerActivity
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"docked": make_entity_state_trigger(DOMAIN, LawnMowerActivity.DOCKED),
|
||||
"errored": make_entity_state_trigger(DOMAIN, LawnMowerActivity.ERROR),
|
||||
"paused_mowing": make_entity_state_trigger(DOMAIN, LawnMowerActivity.PAUSED),
|
||||
"started_mowing": make_entity_state_trigger(DOMAIN, LawnMowerActivity.MOWING),
|
||||
"docked": make_entity_target_state_trigger(DOMAIN, LawnMowerActivity.DOCKED),
|
||||
"errored": make_entity_target_state_trigger(DOMAIN, LawnMowerActivity.ERROR),
|
||||
"paused_mowing": make_entity_target_state_trigger(DOMAIN, LawnMowerActivity.PAUSED),
|
||||
"started_mowing": make_entity_target_state_trigger(
|
||||
DOMAIN, LawnMowerActivity.MOWING
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Provides conditions for lights."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any, Final, override
|
||||
from typing import TYPE_CHECKING, Any, Final, Unpack, override
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -10,11 +10,11 @@ from homeassistant.core import HomeAssistant, split_entity_id
|
||||
from homeassistant.helpers import config_validation as cv, target
|
||||
from homeassistant.helpers.condition import (
|
||||
Condition,
|
||||
ConditionCheckerType,
|
||||
ConditionChecker,
|
||||
ConditionCheckParams,
|
||||
ConditionConfig,
|
||||
trace_condition_function,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
@@ -61,7 +61,7 @@ class StateConditionBase(Condition):
|
||||
self._state = state
|
||||
|
||||
@override
|
||||
async def async_get_checker(self) -> ConditionCheckerType:
|
||||
async def async_get_checker(self) -> ConditionChecker:
|
||||
"""Get the condition checker."""
|
||||
|
||||
def check_any_match_state(states: list[str]) -> bool:
|
||||
@@ -78,12 +78,11 @@ class StateConditionBase(Condition):
|
||||
elif self._behavior == BEHAVIOR_ALL:
|
||||
matcher = check_all_match_state
|
||||
|
||||
@trace_condition_function
|
||||
def test_state(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool:
|
||||
def test_state(**kwargs: Unpack[ConditionCheckParams]) -> bool:
|
||||
"""Test state condition."""
|
||||
target_selection = target.TargetSelection(self._target)
|
||||
targeted_entities = target.async_extract_referenced_entity_ids(
|
||||
hass, target_selection, expand_group=False
|
||||
self._hass, target_selection, expand_group=False
|
||||
)
|
||||
referenced_entity_ids = targeted_entities.referenced.union(
|
||||
targeted_entities.indirectly_referenced
|
||||
@@ -96,7 +95,7 @@ class StateConditionBase(Condition):
|
||||
light_entity_states = [
|
||||
state.state
|
||||
for entity_id in light_entity_ids
|
||||
if (state := hass.states.get(entity_id))
|
||||
if (state := self._hass.states.get(entity_id))
|
||||
and state.state in STATE_CONDITION_VALID_STATES
|
||||
]
|
||||
return matcher(light_entity_states)
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.trigger import Trigger, make_entity_state_trigger
|
||||
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"turned_off": make_entity_state_trigger(DOMAIN, STATE_OFF),
|
||||
"turned_on": make_entity_state_trigger(DOMAIN, STATE_ON),
|
||||
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
|
||||
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -22,5 +22,19 @@
|
||||
"unlock": {
|
||||
"service": "mdi:lock-open-variant"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"jammed": {
|
||||
"trigger": "mdi:lock-alert"
|
||||
},
|
||||
"locked": {
|
||||
"trigger": "mdi:lock"
|
||||
},
|
||||
"opened": {
|
||||
"trigger": "mdi:lock-open-variant"
|
||||
},
|
||||
"unlocked": {
|
||||
"trigger": "mdi:lock-open-variant"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"common": {
|
||||
"trigger_behavior_description": "The behavior of the targeted locks to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"device_automation": {
|
||||
"action_type": {
|
||||
"lock": "Lock {entity_name}",
|
||||
@@ -50,6 +54,15 @@
|
||||
"message": "The code for {entity_id} doesn't match pattern {code_format}."
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
"first": "First",
|
||||
"last": "Last"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"lock": {
|
||||
"description": "Locks a lock.",
|
||||
@@ -82,5 +95,47 @@
|
||||
"name": "Unlock"
|
||||
}
|
||||
},
|
||||
"title": "Lock"
|
||||
"title": "Lock",
|
||||
"triggers": {
|
||||
"jammed": {
|
||||
"description": "Triggers after one or more locks jam.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::lock::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::lock::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Lock jammed"
|
||||
},
|
||||
"locked": {
|
||||
"description": "Triggers after one or more locks lock.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::lock::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::lock::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Lock locked"
|
||||
},
|
||||
"opened": {
|
||||
"description": "Triggers after one or more locks open.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::lock::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::lock::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Lock opened"
|
||||
},
|
||||
"unlocked": {
|
||||
"description": "Triggers after one or more locks unlock.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::lock::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::lock::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Lock unlocked"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
18
homeassistant/components/lock/trigger.py
Normal file
18
homeassistant/components/lock/trigger.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Provides triggers for locks."""
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
|
||||
|
||||
from .const import DOMAIN, LockState
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"jammed": make_entity_target_state_trigger(DOMAIN, LockState.JAMMED),
|
||||
"locked": make_entity_target_state_trigger(DOMAIN, LockState.LOCKED),
|
||||
"opened": make_entity_target_state_trigger(DOMAIN, LockState.OPEN),
|
||||
"unlocked": make_entity_target_state_trigger(DOMAIN, LockState.UNLOCKED),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for locks."""
|
||||
return TRIGGERS
|
||||
20
homeassistant/components/lock/triggers.yaml
Normal file
20
homeassistant/components/lock/triggers.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
.trigger_common: &trigger_common
|
||||
target:
|
||||
entity:
|
||||
domain: lock
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- first
|
||||
- last
|
||||
- any
|
||||
translation_key: trigger_behavior
|
||||
|
||||
jammed: *trigger_common
|
||||
locked: *trigger_common
|
||||
opened: *trigger_common
|
||||
unlocked: *trigger_common
|
||||
@@ -8,6 +8,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/matter",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["python-matter-server==8.1.0"],
|
||||
"requirements": ["python-matter-server==8.1.2"],
|
||||
"zeroconf": ["_matter._tcp.local.", "_matterc._udp.local."]
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""Provides triggers for media players."""
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.trigger import Trigger, make_conditional_entity_state_trigger
|
||||
from homeassistant.helpers.trigger import Trigger, make_entity_transition_trigger
|
||||
|
||||
from . import MediaPlayerState
|
||||
from .const import DOMAIN
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"stopped_playing": make_conditional_entity_state_trigger(
|
||||
"stopped_playing": make_entity_transition_trigger(
|
||||
DOMAIN,
|
||||
from_states={
|
||||
MediaPlayerState.BUFFERING,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"domain": "meteo_france",
|
||||
"name": "M\u00e9t\u00e9o-France",
|
||||
"name": "Météo-France",
|
||||
"codeowners": ["@hacf-fr", "@oncleben31", "@Quentame"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/meteo_france",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["meteofrance_api"],
|
||||
"requirements": ["meteofrance-api==1.4.0"]
|
||||
|
||||
@@ -62,7 +62,7 @@ SENSOR_TYPES: tuple[MeteoFranceSensorEntityDescription, ...] = (
|
||||
key="pressure",
|
||||
name="Pressure",
|
||||
native_unit_of_measurement=UnitOfPressure.HPA,
|
||||
device_class=SensorDeviceClass.PRESSURE,
|
||||
device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_registry_enabled_default=False,
|
||||
data_path="current_forecast:sea_level",
|
||||
|
||||
@@ -98,50 +98,28 @@ DEVICE_TYPE_TAGS = {
|
||||
}
|
||||
|
||||
|
||||
class StateStatus(IntEnum):
|
||||
class StateStatus(MieleEnum, missing_to_none=True):
|
||||
"""Define appliance states."""
|
||||
|
||||
RESERVED = 0
|
||||
OFF = 1
|
||||
ON = 2
|
||||
PROGRAMMED = 3
|
||||
WAITING_TO_START = 4
|
||||
IN_USE = 5
|
||||
PAUSE = 6
|
||||
PROGRAM_ENDED = 7
|
||||
FAILURE = 8
|
||||
PROGRAM_INTERRUPTED = 9
|
||||
IDLE = 10
|
||||
RINSE_HOLD = 11
|
||||
SERVICE = 12
|
||||
SUPERFREEZING = 13
|
||||
SUPERCOOLING = 14
|
||||
SUPERHEATING = 15
|
||||
SUPERCOOLING_SUPERFREEZING = 146
|
||||
AUTOCLEANING = 147
|
||||
NOT_CONNECTED = 255
|
||||
|
||||
|
||||
STATE_STATUS_TAGS = {
|
||||
StateStatus.OFF: "off",
|
||||
StateStatus.ON: "on",
|
||||
StateStatus.PROGRAMMED: "programmed",
|
||||
StateStatus.WAITING_TO_START: "waiting_to_start",
|
||||
StateStatus.IN_USE: "in_use",
|
||||
StateStatus.PAUSE: "pause",
|
||||
StateStatus.PROGRAM_ENDED: "program_ended",
|
||||
StateStatus.FAILURE: "failure",
|
||||
StateStatus.PROGRAM_INTERRUPTED: "program_interrupted",
|
||||
StateStatus.IDLE: "idle",
|
||||
StateStatus.RINSE_HOLD: "rinse_hold",
|
||||
StateStatus.SERVICE: "service",
|
||||
StateStatus.SUPERFREEZING: "superfreezing",
|
||||
StateStatus.SUPERCOOLING: "supercooling",
|
||||
StateStatus.SUPERHEATING: "superheating",
|
||||
StateStatus.SUPERCOOLING_SUPERFREEZING: "supercooling_superfreezing",
|
||||
StateStatus.AUTOCLEANING: "autocleaning",
|
||||
StateStatus.NOT_CONNECTED: "not_connected",
|
||||
}
|
||||
reserved = 0
|
||||
off = 1
|
||||
on = 2
|
||||
programmed = 3
|
||||
waiting_to_start = 4
|
||||
in_use = 5
|
||||
pause = 6
|
||||
program_ended = 7
|
||||
failure = 8
|
||||
program_interrupted = 9
|
||||
idle = 10
|
||||
rinse_hold = 11
|
||||
service = 12
|
||||
superfreezing = 13
|
||||
supercooling = 14
|
||||
superheating = 15
|
||||
supercooling_superfreezing = 146
|
||||
autocleaning = 147
|
||||
not_connected = 255
|
||||
|
||||
|
||||
class MieleActions(IntEnum):
|
||||
|
||||
@@ -73,5 +73,5 @@ class MieleEntity(CoordinatorEntity[MieleDataUpdateCoordinator]):
|
||||
return (
|
||||
super().available
|
||||
and self._device_id in self.coordinator.data.devices
|
||||
and (self.device.state_status is not StateStatus.NOT_CONNECTED)
|
||||
and (self.device.state_status is not StateStatus.not_connected)
|
||||
)
|
||||
|
||||
@@ -38,7 +38,6 @@ from .const import (
|
||||
DOMAIN,
|
||||
PROGRAM_IDS,
|
||||
PROGRAM_PHASE,
|
||||
STATE_STATUS_TAGS,
|
||||
MieleAppliance,
|
||||
PlatePowerStep,
|
||||
StateDryingStep,
|
||||
@@ -195,7 +194,7 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = (
|
||||
translation_key="status",
|
||||
value_fn=lambda value: value.state_status,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=sorted(set(STATE_STATUS_TAGS.values())),
|
||||
options=sorted(set(StateStatus.keys())),
|
||||
),
|
||||
),
|
||||
MieleSensorDefinition(
|
||||
@@ -930,7 +929,7 @@ class MieleStatusSensor(MieleSensor):
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the sensor."""
|
||||
return STATE_STATUS_TAGS.get(StateStatus(self.device.state_status))
|
||||
return StateStatus(self.device.state_status).name
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
@@ -998,11 +997,11 @@ class MieleTimeSensor(MieleRestorableSensor):
|
||||
"""Update the last value of the sensor."""
|
||||
|
||||
current_value = self.entity_description.value_fn(self.device)
|
||||
current_status = StateStatus(self.device.state_status)
|
||||
current_status = StateStatus(self.device.state_status).name
|
||||
|
||||
# report end-specific value when program ends (some devices are immediately reporting 0...)
|
||||
if (
|
||||
current_status == StateStatus.PROGRAM_ENDED
|
||||
current_status == StateStatus.program_ended.name
|
||||
and self.entity_description.end_value_fn is not None
|
||||
):
|
||||
self._attr_native_value = self.entity_description.end_value_fn(
|
||||
@@ -1010,11 +1009,15 @@ class MieleTimeSensor(MieleRestorableSensor):
|
||||
)
|
||||
|
||||
# keep value when program ends if no function is specified
|
||||
elif current_status == StateStatus.PROGRAM_ENDED:
|
||||
elif current_status == StateStatus.program_ended.name:
|
||||
pass
|
||||
|
||||
# force unknown when appliance is not working (some devices are keeping last value until a new cycle starts)
|
||||
elif current_status in (StateStatus.OFF, StateStatus.ON, StateStatus.IDLE):
|
||||
elif current_status in (
|
||||
StateStatus.off.name,
|
||||
StateStatus.on.name,
|
||||
StateStatus.idle.name,
|
||||
):
|
||||
self._attr_native_value = None
|
||||
|
||||
# otherwise, cache value and return it
|
||||
@@ -1030,7 +1033,7 @@ class MieleAbsoluteTimeSensor(MieleRestorableSensor):
|
||||
def _update_native_value(self) -> None:
|
||||
"""Update the last value of the sensor."""
|
||||
current_value = self.entity_description.value_fn(self.device)
|
||||
current_status = StateStatus(self.device.state_status)
|
||||
current_status = StateStatus(self.device.state_status).name
|
||||
|
||||
# The API reports with minute precision, to avoid changing
|
||||
# the value too often, we keep the cached value if it differs
|
||||
@@ -1043,11 +1046,15 @@ class MieleAbsoluteTimeSensor(MieleRestorableSensor):
|
||||
< current_value
|
||||
< self._previous_value + timedelta(seconds=90)
|
||||
)
|
||||
) or current_status == StateStatus.PROGRAM_ENDED:
|
||||
) or current_status == StateStatus.program_ended.name:
|
||||
return
|
||||
|
||||
# force unknown when appliance is not working (some devices are keeping last value until a new cycle starts)
|
||||
if current_status in (StateStatus.OFF, StateStatus.ON, StateStatus.IDLE):
|
||||
if current_status in (
|
||||
StateStatus.off.name,
|
||||
StateStatus.on.name,
|
||||
StateStatus.idle.name,
|
||||
):
|
||||
self._attr_native_value = None
|
||||
|
||||
# otherwise, cache value and return it
|
||||
@@ -1064,7 +1071,7 @@ class MieleConsumptionSensor(MieleRestorableSensor):
|
||||
def _update_native_value(self) -> None:
|
||||
"""Update the last value of the sensor."""
|
||||
current_value = self.entity_description.value_fn(self.device)
|
||||
current_status = StateStatus(self.device.state_status)
|
||||
current_status = StateStatus(self.device.state_status).name
|
||||
# Guard for corrupt restored value
|
||||
restored_value = (
|
||||
self._attr_native_value
|
||||
@@ -1079,12 +1086,12 @@ class MieleConsumptionSensor(MieleRestorableSensor):
|
||||
|
||||
# Force unknown when appliance is not able to report consumption
|
||||
if current_status in (
|
||||
StateStatus.ON,
|
||||
StateStatus.OFF,
|
||||
StateStatus.PROGRAMMED,
|
||||
StateStatus.WAITING_TO_START,
|
||||
StateStatus.IDLE,
|
||||
StateStatus.SERVICE,
|
||||
StateStatus.on.name,
|
||||
StateStatus.off.name,
|
||||
StateStatus.programmed.name,
|
||||
StateStatus.waiting_to_start.name,
|
||||
StateStatus.idle.name,
|
||||
StateStatus.service.name,
|
||||
):
|
||||
self._is_reporting = False
|
||||
self._attr_native_value = None
|
||||
@@ -1093,7 +1100,7 @@ class MieleConsumptionSensor(MieleRestorableSensor):
|
||||
# only after a while, so it is necessary to force 0 until we see the 0 value coming from API, unless
|
||||
# we already saw a valid value in this cycle from cache
|
||||
elif (
|
||||
current_status in (StateStatus.IN_USE, StateStatus.PAUSE)
|
||||
current_status in (StateStatus.in_use.name, StateStatus.pause.name)
|
||||
and not self._is_reporting
|
||||
and last_value > 0
|
||||
):
|
||||
@@ -1101,7 +1108,7 @@ class MieleConsumptionSensor(MieleRestorableSensor):
|
||||
self._is_reporting = True
|
||||
|
||||
elif (
|
||||
current_status in (StateStatus.IN_USE, StateStatus.PAUSE)
|
||||
current_status in (StateStatus.in_use.name, StateStatus.pause.name)
|
||||
and not self._is_reporting
|
||||
and current_value is not None
|
||||
and cast(int, current_value) > 0
|
||||
@@ -1109,7 +1116,7 @@ class MieleConsumptionSensor(MieleRestorableSensor):
|
||||
self._attr_native_value = 0
|
||||
|
||||
# keep value when program ends
|
||||
elif current_status == StateStatus.PROGRAM_ENDED:
|
||||
elif current_status == StateStatus.program_ended.name:
|
||||
pass
|
||||
|
||||
else:
|
||||
|
||||
@@ -1061,6 +1061,7 @@
|
||||
"program_ended": "Program ended",
|
||||
"program_interrupted": "Program interrupted",
|
||||
"programmed": "Programmed",
|
||||
"reserved": "Reserved",
|
||||
"rinse_hold": "Rinse hold",
|
||||
"service": "Service",
|
||||
"supercooling": "Supercooling",
|
||||
|
||||
@@ -58,7 +58,7 @@ SWITCH_TYPES: Final[tuple[MieleSwitchDefinition, ...]] = (
|
||||
description=MieleSwitchDescription(
|
||||
key="supercooling",
|
||||
value_fn=lambda value: value.state_status,
|
||||
on_value=StateStatus.SUPERCOOLING,
|
||||
on_value=StateStatus.supercooling,
|
||||
translation_key="supercooling",
|
||||
on_cmd_data={PROCESS_ACTION: MieleActions.START_SUPERCOOL},
|
||||
off_cmd_data={PROCESS_ACTION: MieleActions.STOP_SUPERCOOL},
|
||||
@@ -73,7 +73,7 @@ SWITCH_TYPES: Final[tuple[MieleSwitchDefinition, ...]] = (
|
||||
description=MieleSwitchDescription(
|
||||
key="superfreezing",
|
||||
value_fn=lambda value: value.state_status,
|
||||
on_value=StateStatus.SUPERFREEZING,
|
||||
on_value=StateStatus.superfreezing,
|
||||
translation_key="superfreezing",
|
||||
on_cmd_data={PROCESS_ACTION: MieleActions.START_SUPERFREEZE},
|
||||
off_cmd_data={PROCESS_ACTION: MieleActions.STOP_SUPERFREEZE},
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/mill",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["mill", "mill_local"],
|
||||
"requirements": ["millheater==0.14.1", "mill-local==0.3.0"]
|
||||
"requirements": ["millheater==0.14.1", "mill-local==0.5.0"]
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ _PLATFORMS: list[Platform] = [
|
||||
Platform.TIME,
|
||||
Platform.SWITCH,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
]
|
||||
|
||||
PLATFORM_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user