mirror of
https://github.com/home-assistant/core.git
synced 2025-08-06 06:05:10 +02:00
Merge branch 'dev' into jbouwh-mqtt-device-discovery
This commit is contained in:
18
.coveragerc
18
.coveragerc
@@ -471,7 +471,6 @@ omit =
|
||||
homeassistant/components/frontier_silicon/browse_media.py
|
||||
homeassistant/components/frontier_silicon/media_player.py
|
||||
homeassistant/components/futurenow/light.py
|
||||
homeassistant/components/fyta/__init__.py
|
||||
homeassistant/components/fyta/coordinator.py
|
||||
homeassistant/components/fyta/entity.py
|
||||
homeassistant/components/fyta/sensor.py
|
||||
@@ -730,7 +729,6 @@ omit =
|
||||
homeassistant/components/lookin/sensor.py
|
||||
homeassistant/components/loqed/sensor.py
|
||||
homeassistant/components/luci/device_tracker.py
|
||||
homeassistant/components/luftdaten/sensor.py
|
||||
homeassistant/components/lupusec/__init__.py
|
||||
homeassistant/components/lupusec/alarm_control_panel.py
|
||||
homeassistant/components/lupusec/binary_sensor.py
|
||||
@@ -805,10 +803,8 @@ omit =
|
||||
homeassistant/components/mochad/switch.py
|
||||
homeassistant/components/modem_callerid/button.py
|
||||
homeassistant/components/modem_callerid/sensor.py
|
||||
homeassistant/components/moehlenhoff_alpha2/__init__.py
|
||||
homeassistant/components/moehlenhoff_alpha2/binary_sensor.py
|
||||
homeassistant/components/moehlenhoff_alpha2/climate.py
|
||||
homeassistant/components/moehlenhoff_alpha2/sensor.py
|
||||
homeassistant/components/moehlenhoff_alpha2/coordinator.py
|
||||
homeassistant/components/monzo/__init__.py
|
||||
homeassistant/components/monzo/api.py
|
||||
homeassistant/components/motion_blinds/__init__.py
|
||||
@@ -920,9 +916,8 @@ omit =
|
||||
homeassistant/components/notion/util.py
|
||||
homeassistant/components/nsw_fuel_station/sensor.py
|
||||
homeassistant/components/nuki/__init__.py
|
||||
homeassistant/components/nuki/binary_sensor.py
|
||||
homeassistant/components/nuki/coordinator.py
|
||||
homeassistant/components/nuki/lock.py
|
||||
homeassistant/components/nuki/sensor.py
|
||||
homeassistant/components/nx584/alarm_control_panel.py
|
||||
homeassistant/components/oasa_telematics/sensor.py
|
||||
homeassistant/components/obihai/__init__.py
|
||||
@@ -935,7 +930,7 @@ omit =
|
||||
homeassistant/components/ohmconnect/sensor.py
|
||||
homeassistant/components/ombi/*
|
||||
homeassistant/components/omnilogic/__init__.py
|
||||
homeassistant/components/omnilogic/common.py
|
||||
homeassistant/components/omnilogic/coordinator.py
|
||||
homeassistant/components/omnilogic/sensor.py
|
||||
homeassistant/components/omnilogic/switch.py
|
||||
homeassistant/components/ondilo_ico/__init__.py
|
||||
@@ -975,6 +970,7 @@ omit =
|
||||
homeassistant/components/openuv/sensor.py
|
||||
homeassistant/components/openweathermap/__init__.py
|
||||
homeassistant/components/openweathermap/coordinator.py
|
||||
homeassistant/components/openweathermap/repairs.py
|
||||
homeassistant/components/openweathermap/sensor.py
|
||||
homeassistant/components/openweathermap/weather.py
|
||||
homeassistant/components/opnsense/__init__.py
|
||||
@@ -1097,6 +1093,7 @@ omit =
|
||||
homeassistant/components/rainmachine/__init__.py
|
||||
homeassistant/components/rainmachine/binary_sensor.py
|
||||
homeassistant/components/rainmachine/button.py
|
||||
homeassistant/components/rainmachine/coordinator.py
|
||||
homeassistant/components/rainmachine/select.py
|
||||
homeassistant/components/rainmachine/sensor.py
|
||||
homeassistant/components/rainmachine/switch.py
|
||||
@@ -1430,6 +1427,7 @@ omit =
|
||||
homeassistant/components/thinkingcleaner/*
|
||||
homeassistant/components/thomson/device_tracker.py
|
||||
homeassistant/components/tibber/__init__.py
|
||||
homeassistant/components/tibber/coordinator.py
|
||||
homeassistant/components/tibber/sensor.py
|
||||
homeassistant/components/tikteck/light.py
|
||||
homeassistant/components/tile/__init__.py
|
||||
@@ -1707,10 +1705,6 @@ omit =
|
||||
homeassistant/components/zeroconf/models.py
|
||||
homeassistant/components/zeroconf/usage.py
|
||||
homeassistant/components/zestimate/sensor.py
|
||||
homeassistant/components/zeversolar/__init__.py
|
||||
homeassistant/components/zeversolar/coordinator.py
|
||||
homeassistant/components/zeversolar/entity.py
|
||||
homeassistant/components/zeversolar/sensor.py
|
||||
homeassistant/components/zha/core/cluster_handlers/*
|
||||
homeassistant/components/zha/core/device.py
|
||||
homeassistant/components/zha/core/gateway.py
|
||||
|
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.4.4
|
||||
rev: v0.4.5
|
||||
hooks:
|
||||
- id: ruff
|
||||
args:
|
||||
@@ -8,11 +8,11 @@ repos:
|
||||
- id: ruff-format
|
||||
files: ^((homeassistant|pylint|script|tests)/.+)?[^/]+\.(py|pyi)$
|
||||
- repo: https://github.com/codespell-project/codespell
|
||||
rev: v2.2.6
|
||||
rev: v2.3.0
|
||||
hooks:
|
||||
- id: codespell
|
||||
args:
|
||||
- --ignore-words-list=additionals,alle,alot,astroid,bund,caf,convencional,currenty,datas,farenheit,falsy,fo,frequence,haa,hass,iif,incomfort,ines,ist,nam,nd,pres,pullrequests,resset,rime,ser,serie,te,technik,ue,unsecure,vor,withing,zar
|
||||
- --ignore-words-list=astroid,checkin,currenty,hass,iif,incomfort,lookin,nam,NotIn,pres,ser,ue
|
||||
- --skip="./.*,*.csv,*.json,*.ambr"
|
||||
- --quiet-level=2
|
||||
exclude_types: [csv, json, html]
|
||||
|
@@ -163,6 +163,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/awair/ @ahayworth @danielsjf
|
||||
/homeassistant/components/axis/ @Kane610
|
||||
/tests/components/axis/ @Kane610
|
||||
/homeassistant/components/azure_data_explorer/ @kaareseras
|
||||
/tests/components/azure_data_explorer/ @kaareseras
|
||||
/homeassistant/components/azure_devops/ @timmo001
|
||||
/tests/components/azure_devops/ @timmo001
|
||||
/homeassistant/components/azure_event_hub/ @eavanvalkenburg
|
||||
|
@@ -5,7 +5,7 @@
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
identity and expression, level of experience, education, socioeconomic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
|
@@ -28,7 +28,6 @@ from .const import ACCESS_TOKEN_EXPIRATION, GROUP_ID_ADMIN, REFRESH_TOKEN_EXPIRA
|
||||
from .mfa_modules import MultiFactorAuthModule, auth_mfa_module_from_config
|
||||
from .models import AuthFlowResult
|
||||
from .providers import AuthProvider, LoginFlow, auth_provider_from_config
|
||||
from .session import SessionManager
|
||||
|
||||
EVENT_USER_ADDED = "user_added"
|
||||
EVENT_USER_UPDATED = "user_updated"
|
||||
@@ -181,7 +180,6 @@ class AuthManager:
|
||||
self._remove_expired_job = HassJob(
|
||||
self._async_remove_expired_refresh_tokens, job_type=HassJobType.Callback
|
||||
)
|
||||
self.session = SessionManager(hass, self)
|
||||
|
||||
async def async_setup(self) -> None:
|
||||
"""Set up the auth manager."""
|
||||
@@ -192,7 +190,6 @@ class AuthManager:
|
||||
)
|
||||
)
|
||||
self._async_track_next_refresh_token_expiration()
|
||||
await self.session.async_setup()
|
||||
|
||||
@property
|
||||
def auth_providers(self) -> list[AuthProvider]:
|
||||
|
@@ -1,205 +0,0 @@
|
||||
"""Session auth module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import secrets
|
||||
from typing import TYPE_CHECKING, Final, TypedDict
|
||||
|
||||
from aiohttp.web import Request
|
||||
from aiohttp_session import Session, get_session, new_session
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .models import RefreshToken
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import AuthManager
|
||||
|
||||
|
||||
TEMP_TIMEOUT = timedelta(minutes=5)
|
||||
TEMP_TIMEOUT_SECONDS = TEMP_TIMEOUT.total_seconds()
|
||||
|
||||
SESSION_ID = "id"
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_KEY = "auth.session"
|
||||
|
||||
|
||||
class StrictConnectionTempSessionData:
|
||||
"""Data for accessing unauthorized resources for a short period of time."""
|
||||
|
||||
__slots__ = ("cancel_remove", "absolute_expiry")
|
||||
|
||||
def __init__(self, cancel_remove: CALLBACK_TYPE) -> None:
|
||||
"""Initialize the temp session data."""
|
||||
self.cancel_remove: Final[CALLBACK_TYPE] = cancel_remove
|
||||
self.absolute_expiry: Final[datetime] = dt_util.utcnow() + TEMP_TIMEOUT
|
||||
|
||||
|
||||
class StoreData(TypedDict):
|
||||
"""Data to store."""
|
||||
|
||||
unauthorized_sessions: dict[str, str]
|
||||
key: str
|
||||
|
||||
|
||||
class SessionManager:
|
||||
"""Session manager."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, auth: AuthManager) -> None:
|
||||
"""Initialize the strict connection manager."""
|
||||
self._auth = auth
|
||||
self._hass = hass
|
||||
self._temp_sessions: dict[str, StrictConnectionTempSessionData] = {}
|
||||
self._strict_connection_sessions: dict[str, str] = {}
|
||||
self._store = Store[StoreData](
|
||||
hass, STORAGE_VERSION, STORAGE_KEY, private=True, atomic_writes=True
|
||||
)
|
||||
self._key: str | None = None
|
||||
self._refresh_token_revoke_callbacks: dict[str, CALLBACK_TYPE] = {}
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
"""Return the encryption key."""
|
||||
if self._key is None:
|
||||
self._key = Fernet.generate_key().decode()
|
||||
self._async_schedule_save()
|
||||
return self._key
|
||||
|
||||
async def async_validate_request_for_strict_connection_session(
|
||||
self,
|
||||
request: Request,
|
||||
) -> bool:
|
||||
"""Check if a request has a valid strict connection session."""
|
||||
session = await get_session(request)
|
||||
if session.new or session.empty:
|
||||
return False
|
||||
result = self.async_validate_strict_connection_session(session)
|
||||
if result is False:
|
||||
session.invalidate()
|
||||
return result
|
||||
|
||||
@callback
|
||||
def async_validate_strict_connection_session(
|
||||
self,
|
||||
session: Session,
|
||||
) -> bool:
|
||||
"""Validate a strict connection session."""
|
||||
if not (session_id := session.get(SESSION_ID)):
|
||||
return False
|
||||
|
||||
if token_id := self._strict_connection_sessions.get(session_id):
|
||||
if self._auth.async_get_refresh_token(token_id):
|
||||
return True
|
||||
# refresh token is invalid, delete entry
|
||||
self._strict_connection_sessions.pop(session_id)
|
||||
self._async_schedule_save()
|
||||
|
||||
if data := self._temp_sessions.get(session_id):
|
||||
if dt_util.utcnow() <= data.absolute_expiry:
|
||||
return True
|
||||
# session expired, delete entry
|
||||
self._temp_sessions.pop(session_id).cancel_remove()
|
||||
|
||||
return False
|
||||
|
||||
@callback
|
||||
def _async_register_revoke_token_callback(self, refresh_token_id: str) -> None:
|
||||
"""Register a callback to revoke all sessions for a refresh token."""
|
||||
if refresh_token_id in self._refresh_token_revoke_callbacks:
|
||||
return
|
||||
|
||||
@callback
|
||||
def async_invalidate_auth_sessions() -> None:
|
||||
"""Invalidate all sessions for a refresh token."""
|
||||
self._strict_connection_sessions = {
|
||||
session_id: token_id
|
||||
for session_id, token_id in self._strict_connection_sessions.items()
|
||||
if token_id != refresh_token_id
|
||||
}
|
||||
self._async_schedule_save()
|
||||
|
||||
self._refresh_token_revoke_callbacks[refresh_token_id] = (
|
||||
self._auth.async_register_revoke_token_callback(
|
||||
refresh_token_id, async_invalidate_auth_sessions
|
||||
)
|
||||
)
|
||||
|
||||
async def async_create_session(
|
||||
self,
|
||||
request: Request,
|
||||
refresh_token: RefreshToken,
|
||||
) -> None:
|
||||
"""Create new session for given refresh token.
|
||||
|
||||
Caller needs to make sure that the refresh token is valid.
|
||||
By creating a session, we are implicitly revoking all other
|
||||
sessions for the given refresh token as there is one refresh
|
||||
token per device/user case.
|
||||
"""
|
||||
self._strict_connection_sessions = {
|
||||
session_id: token_id
|
||||
for session_id, token_id in self._strict_connection_sessions.items()
|
||||
if token_id != refresh_token.id
|
||||
}
|
||||
|
||||
self._async_register_revoke_token_callback(refresh_token.id)
|
||||
session_id = await self._async_create_new_session(request)
|
||||
self._strict_connection_sessions[session_id] = refresh_token.id
|
||||
self._async_schedule_save()
|
||||
|
||||
async def async_create_temp_unauthorized_session(self, request: Request) -> None:
|
||||
"""Create a temporary unauthorized session."""
|
||||
session_id = await self._async_create_new_session(
|
||||
request, max_age=int(TEMP_TIMEOUT_SECONDS)
|
||||
)
|
||||
|
||||
@callback
|
||||
def remove(_: datetime) -> None:
|
||||
self._temp_sessions.pop(session_id, None)
|
||||
|
||||
self._temp_sessions[session_id] = StrictConnectionTempSessionData(
|
||||
async_call_later(self._hass, TEMP_TIMEOUT_SECONDS, remove)
|
||||
)
|
||||
|
||||
async def _async_create_new_session(
|
||||
self,
|
||||
request: Request,
|
||||
*,
|
||||
max_age: int | None = None,
|
||||
) -> str:
|
||||
session_id = secrets.token_hex(64)
|
||||
|
||||
session = await new_session(request)
|
||||
session[SESSION_ID] = session_id
|
||||
if max_age is not None:
|
||||
session.max_age = max_age
|
||||
return session_id
|
||||
|
||||
@callback
|
||||
def _async_schedule_save(self, delay: float = 1) -> None:
|
||||
"""Save sessions."""
|
||||
self._store.async_delay_save(self._data_to_save, delay)
|
||||
|
||||
@callback
|
||||
def _data_to_save(self) -> StoreData:
|
||||
"""Return the data to store."""
|
||||
return StoreData(
|
||||
unauthorized_sessions=self._strict_connection_sessions,
|
||||
key=self.key,
|
||||
)
|
||||
|
||||
async def async_setup(self) -> None:
|
||||
"""Set up session manager."""
|
||||
data = await self._store.async_load()
|
||||
if data is None:
|
||||
return
|
||||
|
||||
self._key = data["key"]
|
||||
self._strict_connection_sessions = data["unauthorized_sessions"]
|
||||
for token_id in self._strict_connection_sessions.values():
|
||||
self._async_register_revoke_token_callback(token_id)
|
@@ -421,6 +421,9 @@ async def async_from_config_dict(
|
||||
start = monotonic()
|
||||
|
||||
hass.config_entries = config_entries.ConfigEntries(hass, config)
|
||||
# Prime custom component cache early so we know if registry entries are tied
|
||||
# to a custom integration
|
||||
await loader.async_get_custom_components(hass)
|
||||
await async_load_base_functionality(hass)
|
||||
|
||||
# Set up core.
|
||||
|
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airgradient",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["airgradient==0.4.0"],
|
||||
"requirements": ["airgradient==0.4.1"],
|
||||
"zeroconf": ["_airgradient._tcp.local."]
|
||||
}
|
||||
|
@@ -66,7 +66,7 @@ SUPPORTED_VOICES: Final[list[str]] = [
|
||||
"Hans", # German
|
||||
"Hiujin", # Chinese (Cantonese), Neural
|
||||
"Ida", # Norwegian, Neural
|
||||
"Ines", # Portuguese, European
|
||||
"Ines", # Portuguese, European # codespell:ignore ines
|
||||
"Ivy", # English
|
||||
"Jacek", # Polish
|
||||
"Jan", # Polish
|
||||
|
@@ -39,6 +39,7 @@ ATTR_COURSE = "course"
|
||||
ATTR_COMMENT = "comment"
|
||||
ATTR_FROM = "from"
|
||||
ATTR_FORMAT = "format"
|
||||
ATTR_OBJECT_NAME = "object_name"
|
||||
ATTR_POS_AMBIGUITY = "posambiguity"
|
||||
ATTR_SPEED = "speed"
|
||||
|
||||
@@ -50,7 +51,7 @@ DEFAULT_TIMEOUT = 30.0
|
||||
|
||||
FILTER_PORT = 14580
|
||||
|
||||
MSG_FORMATS = ["compressed", "uncompressed", "mic-e"]
|
||||
MSG_FORMATS = ["compressed", "uncompressed", "mic-e", "object"]
|
||||
|
||||
PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
@@ -181,7 +182,10 @@ class AprsListenerThread(threading.Thread):
|
||||
"""Receive message and process if position."""
|
||||
_LOGGER.debug("APRS message received: %s", str(msg))
|
||||
if msg[ATTR_FORMAT] in MSG_FORMATS:
|
||||
dev_id = slugify(msg[ATTR_FROM])
|
||||
if msg[ATTR_FORMAT] == "object":
|
||||
dev_id = slugify(msg[ATTR_OBJECT_NAME])
|
||||
else:
|
||||
dev_id = slugify(msg[ATTR_FROM])
|
||||
lat = msg[ATTR_LATITUDE]
|
||||
lon = msg[ATTR_LONGITUDE]
|
||||
|
||||
|
@@ -162,7 +162,6 @@ from homeassistant.util import dt as dt_util
|
||||
from . import indieauth, login_flow, mfa_setup_flow
|
||||
|
||||
DOMAIN = "auth"
|
||||
STRICT_CONNECTION_URL = "/auth/strict_connection/temp_token"
|
||||
|
||||
type StoreResultType = Callable[[str, Credentials], str]
|
||||
type RetrieveResultType = Callable[[str, str], Credentials | None]
|
||||
@@ -188,7 +187,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
hass.http.register_view(RevokeTokenView())
|
||||
hass.http.register_view(LinkUserView(retrieve_result))
|
||||
hass.http.register_view(OAuth2AuthorizeCallbackView())
|
||||
hass.http.register_view(StrictConnectionTempTokenView())
|
||||
|
||||
websocket_api.async_register_command(hass, websocket_current_user)
|
||||
websocket_api.async_register_command(hass, websocket_create_long_lived_access_token)
|
||||
@@ -323,7 +321,6 @@ class TokenView(HomeAssistantView):
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
|
||||
await hass.auth.session.async_create_session(request, refresh_token)
|
||||
return self.json(
|
||||
{
|
||||
"access_token": access_token,
|
||||
@@ -392,7 +389,6 @@ class TokenView(HomeAssistantView):
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
|
||||
await hass.auth.session.async_create_session(request, refresh_token)
|
||||
return self.json(
|
||||
{
|
||||
"access_token": access_token,
|
||||
@@ -441,20 +437,6 @@ class LinkUserView(HomeAssistantView):
|
||||
return self.json_message("User linked")
|
||||
|
||||
|
||||
class StrictConnectionTempTokenView(HomeAssistantView):
|
||||
"""View to get temporary strict connection token."""
|
||||
|
||||
url = STRICT_CONNECTION_URL
|
||||
name = "api:auth:strict_connection:temp_token"
|
||||
requires_auth = False
|
||||
|
||||
async def get(self, request: web.Request) -> web.Response:
|
||||
"""Get a temporary token and redirect to main page."""
|
||||
hass = request.app[KEY_HASS]
|
||||
await hass.auth.session.async_create_temp_unauthorized_session(request)
|
||||
raise web.HTTPSeeOther(location="/")
|
||||
|
||||
|
||||
@callback
|
||||
def _create_auth_code_store() -> tuple[StoreResultType, RetrieveResultType]:
|
||||
"""Create an in memory store."""
|
||||
|
212
homeassistant/components/azure_data_explorer/__init__.py
Normal file
212
homeassistant/components/azure_data_explorer/__init__.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""The Azure Data Explorer integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import json
|
||||
import logging
|
||||
|
||||
from azure.kusto.data.exceptions import KustoAuthenticationError, KustoServiceError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import MATCH_ALL
|
||||
from homeassistant.core import Event, HomeAssistant, State
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
from homeassistant.helpers.entityfilter import FILTER_SCHEMA
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.json import JSONEncoder
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from .client import AzureDataExplorerClient
|
||||
from .const import (
|
||||
CONF_APP_REG_SECRET,
|
||||
CONF_FILTER,
|
||||
CONF_SEND_INTERVAL,
|
||||
DATA_FILTER,
|
||||
DATA_HUB,
|
||||
DEFAULT_MAX_DELAY,
|
||||
DOMAIN,
|
||||
FILTER_STATES,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
DOMAIN: vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_FILTER, default={}): FILTER_SCHEMA,
|
||||
},
|
||||
)
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
|
||||
# fixtures for both init and config flow tests
|
||||
@dataclass
|
||||
class FilterTest:
|
||||
"""Class for capturing a filter test."""
|
||||
|
||||
entity_id: str
|
||||
expect_called: bool
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool:
|
||||
"""Activate ADX component from yaml.
|
||||
|
||||
Adds an empty filter to hass data.
|
||||
Tries to get a filter from yaml, if present set to hass data.
|
||||
If config is empty after getting the filter, return, otherwise emit
|
||||
deprecated warning and pass the rest to the config flow.
|
||||
"""
|
||||
|
||||
hass.data.setdefault(DOMAIN, {DATA_FILTER: {}})
|
||||
if DOMAIN in yaml_config:
|
||||
hass.data[DOMAIN][DATA_FILTER] = yaml_config[DOMAIN][CONF_FILTER]
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Do the setup based on the config entry and the filter from yaml."""
|
||||
adx = AzureDataExplorer(hass, entry)
|
||||
try:
|
||||
await adx.test_connection()
|
||||
except KustoServiceError as exp:
|
||||
raise ConfigEntryError(
|
||||
"Could not find Azure Data Explorer database or table"
|
||||
) from exp
|
||||
except KustoAuthenticationError:
|
||||
return False
|
||||
|
||||
hass.data[DOMAIN][DATA_HUB] = adx
|
||||
await adx.async_start()
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
adx = hass.data[DOMAIN].pop(DATA_HUB)
|
||||
await adx.async_stop()
|
||||
return True
|
||||
|
||||
|
||||
class AzureDataExplorer:
|
||||
"""A event handler class for Azure Data Explorer."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize the listener."""
|
||||
|
||||
self.hass = hass
|
||||
self._entry = entry
|
||||
self._entities_filter = hass.data[DOMAIN][DATA_FILTER]
|
||||
|
||||
self._client = AzureDataExplorerClient(entry.data)
|
||||
|
||||
self._send_interval = entry.options[CONF_SEND_INTERVAL]
|
||||
self._client_secret = entry.data[CONF_APP_REG_SECRET]
|
||||
self._max_delay = DEFAULT_MAX_DELAY
|
||||
|
||||
self._shutdown = False
|
||||
self._queue: asyncio.Queue[tuple[datetime, State]] = asyncio.Queue()
|
||||
self._listener_remover: Callable[[], None] | None = None
|
||||
self._next_send_remover: Callable[[], None] | None = None
|
||||
|
||||
async def async_start(self) -> None:
|
||||
"""Start the component.
|
||||
|
||||
This register the listener and
|
||||
schedules the first send.
|
||||
"""
|
||||
|
||||
self._listener_remover = self.hass.bus.async_listen(
|
||||
MATCH_ALL, self.async_listen
|
||||
)
|
||||
self._schedule_next_send()
|
||||
|
||||
async def async_stop(self) -> None:
|
||||
"""Shut down the ADX by queueing None, calling send, join queue."""
|
||||
if self._next_send_remover:
|
||||
self._next_send_remover()
|
||||
if self._listener_remover:
|
||||
self._listener_remover()
|
||||
self._shutdown = True
|
||||
await self.async_send(None)
|
||||
|
||||
async def test_connection(self) -> None:
|
||||
"""Test the connection to the Azure Data Explorer service."""
|
||||
await self.hass.async_add_executor_job(self._client.test_connection)
|
||||
|
||||
def _schedule_next_send(self) -> None:
|
||||
"""Schedule the next send."""
|
||||
if not self._shutdown:
|
||||
if self._next_send_remover:
|
||||
self._next_send_remover()
|
||||
self._next_send_remover = async_call_later(
|
||||
self.hass, self._send_interval, self.async_send
|
||||
)
|
||||
|
||||
async def async_listen(self, event: Event) -> None:
|
||||
"""Listen for new messages on the bus and queue them for ADX."""
|
||||
if state := event.data.get("new_state"):
|
||||
await self._queue.put((event.time_fired, state))
|
||||
|
||||
async def async_send(self, _) -> None:
|
||||
"""Write preprocessed events to Azure Data Explorer."""
|
||||
|
||||
adx_events = []
|
||||
dropped = 0
|
||||
while not self._queue.empty():
|
||||
(time_fired, event) = self._queue.get_nowait()
|
||||
adx_event, dropped = self._parse_event(time_fired, event, dropped)
|
||||
self._queue.task_done()
|
||||
if adx_event is not None:
|
||||
adx_events.append(adx_event)
|
||||
|
||||
if dropped:
|
||||
_LOGGER.warning(
|
||||
"Dropped %d old events, consider filtering messages", dropped
|
||||
)
|
||||
|
||||
if adx_events:
|
||||
event_string = "".join(adx_events)
|
||||
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
self._client.ingest_data, event_string
|
||||
)
|
||||
|
||||
except KustoServiceError as err:
|
||||
_LOGGER.error("Could not find database or table: %s", err)
|
||||
except KustoAuthenticationError as err:
|
||||
_LOGGER.error("Could not authenticate to Azure Data Explorer: %s", err)
|
||||
|
||||
self._schedule_next_send()
|
||||
|
||||
def _parse_event(
|
||||
self,
|
||||
time_fired: datetime,
|
||||
state: State,
|
||||
dropped: int,
|
||||
) -> tuple[str | None, int]:
|
||||
"""Parse event by checking if it needs to be sent, and format it."""
|
||||
|
||||
if state.state in FILTER_STATES or not self._entities_filter(state.entity_id):
|
||||
return None, dropped
|
||||
if (utcnow() - time_fired).seconds > DEFAULT_MAX_DELAY + self._send_interval:
|
||||
return None, dropped + 1
|
||||
if "\n" in state.state:
|
||||
return None, dropped + 1
|
||||
|
||||
json_event = str(json.dumps(obj=state, cls=JSONEncoder).encode("utf-8"))
|
||||
|
||||
return (json_event, dropped)
|
79
homeassistant/components/azure_data_explorer/client.py
Normal file
79
homeassistant/components/azure_data_explorer/client.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Setting up the Azure Data Explorer ingest client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import io
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from azure.kusto.data import KustoClient, KustoConnectionStringBuilder
|
||||
from azure.kusto.data.data_format import DataFormat
|
||||
from azure.kusto.ingest import (
|
||||
IngestionProperties,
|
||||
ManagedStreamingIngestClient,
|
||||
QueuedIngestClient,
|
||||
StreamDescriptor,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
CONF_ADX_CLUSTER_INGEST_URI,
|
||||
CONF_ADX_DATABASE_NAME,
|
||||
CONF_ADX_TABLE_NAME,
|
||||
CONF_APP_REG_ID,
|
||||
CONF_APP_REG_SECRET,
|
||||
CONF_AUTHORITY_ID,
|
||||
CONF_USE_FREE,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AzureDataExplorerClient:
|
||||
"""Class for Azure Data Explorer Client."""
|
||||
|
||||
def __init__(self, data: Mapping[str, Any]) -> None:
|
||||
"""Create the right class."""
|
||||
|
||||
self._cluster_ingest_uri = data[CONF_ADX_CLUSTER_INGEST_URI]
|
||||
self._database = data[CONF_ADX_DATABASE_NAME]
|
||||
self._table = data[CONF_ADX_TABLE_NAME]
|
||||
self._ingestion_properties = IngestionProperties(
|
||||
database=self._database,
|
||||
table=self._table,
|
||||
data_format=DataFormat.MULTIJSON,
|
||||
ingestion_mapping_reference="ha_json_mapping",
|
||||
)
|
||||
|
||||
# Create cLient for ingesting and querying data
|
||||
kcsb = KustoConnectionStringBuilder.with_aad_application_key_authentication(
|
||||
self._cluster_ingest_uri,
|
||||
data[CONF_APP_REG_ID],
|
||||
data[CONF_APP_REG_SECRET],
|
||||
data[CONF_AUTHORITY_ID],
|
||||
)
|
||||
|
||||
if data[CONF_USE_FREE] is True:
|
||||
# Queded is the only option supported on free tear of ADX
|
||||
self.write_client = QueuedIngestClient(kcsb)
|
||||
else:
|
||||
self.write_client = ManagedStreamingIngestClient.from_dm_kcsb(kcsb)
|
||||
|
||||
self.query_client = KustoClient(kcsb)
|
||||
|
||||
def test_connection(self) -> None:
|
||||
"""Test connection, will throw Exception when it cannot connect."""
|
||||
|
||||
query = f"{self._table} | take 1"
|
||||
|
||||
self.query_client.execute_query(self._database, query)
|
||||
|
||||
def ingest_data(self, adx_events: str) -> None:
|
||||
"""Send data to Axure Data Explorer."""
|
||||
|
||||
bytes_stream = io.StringIO(adx_events)
|
||||
stream_descriptor = StreamDescriptor(bytes_stream)
|
||||
|
||||
self.write_client.ingest_from_stream(
|
||||
stream_descriptor, ingestion_properties=self._ingestion_properties
|
||||
)
|
88
homeassistant/components/azure_data_explorer/config_flow.py
Normal file
88
homeassistant/components/azure_data_explorer/config_flow.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Config flow for Azure Data Explorer integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from azure.kusto.data.exceptions import KustoAuthenticationError, KustoServiceError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.config_entries import ConfigFlowResult
|
||||
|
||||
from . import AzureDataExplorerClient
|
||||
from .const import (
|
||||
CONF_ADX_CLUSTER_INGEST_URI,
|
||||
CONF_ADX_DATABASE_NAME,
|
||||
CONF_ADX_TABLE_NAME,
|
||||
CONF_APP_REG_ID,
|
||||
CONF_APP_REG_SECRET,
|
||||
CONF_AUTHORITY_ID,
|
||||
CONF_USE_FREE,
|
||||
DEFAULT_OPTIONS,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ADX_CLUSTER_INGEST_URI): str,
|
||||
vol.Required(CONF_ADX_DATABASE_NAME): str,
|
||||
vol.Required(CONF_ADX_TABLE_NAME): str,
|
||||
vol.Required(CONF_APP_REG_ID): str,
|
||||
vol.Required(CONF_APP_REG_SECRET): str,
|
||||
vol.Required(CONF_AUTHORITY_ID): str,
|
||||
vol.Optional(CONF_USE_FREE, default=False): bool,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ADXConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Azure Data Explorer."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def validate_input(self, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Validate the user input allows us to connect.
|
||||
|
||||
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
|
||||
"""
|
||||
client = AzureDataExplorerClient(data)
|
||||
|
||||
try:
|
||||
await self.hass.async_add_executor_job(client.test_connection)
|
||||
|
||||
except KustoAuthenticationError as exp:
|
||||
_LOGGER.error(exp)
|
||||
return {"base": "invalid_auth"}
|
||||
|
||||
except KustoServiceError as exp:
|
||||
_LOGGER.error(exp)
|
||||
return {"base": "cannot_connect"}
|
||||
|
||||
return None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
|
||||
errors: dict = {}
|
||||
if user_input:
|
||||
errors = await self.validate_input(user_input) # type: ignore[assignment]
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
data=user_input,
|
||||
title=user_input[CONF_ADX_CLUSTER_INGEST_URI].replace(
|
||||
"https://", ""
|
||||
),
|
||||
options=DEFAULT_OPTIONS,
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
last_step=True,
|
||||
)
|
30
homeassistant/components/azure_data_explorer/const.py
Normal file
30
homeassistant/components/azure_data_explorer/const.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Constants for the Azure Data Explorer integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
|
||||
DOMAIN = "azure_data_explorer"
|
||||
|
||||
CONF_ADX_CLUSTER_INGEST_URI = "cluster_ingest_uri"
|
||||
CONF_ADX_DATABASE_NAME = "database"
|
||||
CONF_ADX_TABLE_NAME = "table"
|
||||
CONF_APP_REG_ID = "client_id"
|
||||
CONF_APP_REG_SECRET = "client_secret"
|
||||
CONF_AUTHORITY_ID = "authority_id"
|
||||
CONF_SEND_INTERVAL = "send_interval"
|
||||
CONF_MAX_DELAY = "max_delay"
|
||||
CONF_FILTER = DATA_FILTER = "filter"
|
||||
CONF_USE_FREE = "use_queued_ingestion"
|
||||
DATA_HUB = "hub"
|
||||
STEP_USER = "user"
|
||||
|
||||
|
||||
DEFAULT_SEND_INTERVAL: int = 5
|
||||
DEFAULT_MAX_DELAY: int = 30
|
||||
DEFAULT_OPTIONS: dict[str, Any] = {CONF_SEND_INTERVAL: DEFAULT_SEND_INTERVAL}
|
||||
|
||||
ADDITIONAL_ARGS: dict[str, Any] = {"logging_enable": False}
|
||||
FILTER_STATES = (STATE_UNKNOWN, STATE_UNAVAILABLE)
|
10
homeassistant/components/azure_data_explorer/manifest.json
Normal file
10
homeassistant/components/azure_data_explorer/manifest.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"domain": "azure_data_explorer",
|
||||
"name": "Azure Data Explorer",
|
||||
"codeowners": ["@kaareseras"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/azure_data_explorer",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["azure"],
|
||||
"requirements": ["azure-kusto-ingest==3.1.0", "azure-kusto-data[aio]==3.1.0"]
|
||||
}
|
26
homeassistant/components/azure_data_explorer/strings.json
Normal file
26
homeassistant/components/azure_data_explorer/strings.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Setup your Azure Data Explorer integration",
|
||||
"description": "Enter connection details.",
|
||||
"data": {
|
||||
"clusteringesturi": "Cluster Ingest URI",
|
||||
"database": "Database name",
|
||||
"table": "Table name",
|
||||
"client_id": "Client ID",
|
||||
"client_secret": "Client secret",
|
||||
"authority_id": "Authority ID"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
}
|
||||
}
|
||||
}
|
@@ -20,6 +20,6 @@
|
||||
"bluetooth-auto-recovery==1.4.2",
|
||||
"bluetooth-data-tools==1.19.0",
|
||||
"dbus-fast==2.21.3",
|
||||
"habluetooth==3.1.0"
|
||||
"habluetooth==3.1.1"
|
||||
]
|
||||
}
|
||||
|
@@ -103,3 +103,9 @@ class TurboJPEGSingleton:
|
||||
"Error loading libturbojpeg; Camera snapshot performance will be sub-optimal"
|
||||
)
|
||||
TurboJPEGSingleton.__instance = False
|
||||
|
||||
|
||||
# TurboJPEG loads libraries that do blocking I/O.
|
||||
# Initialize TurboJPEGSingleton in the executor to avoid
|
||||
# blocking the event loop.
|
||||
TurboJPEGSingleton.instance()
|
||||
|
@@ -7,14 +7,11 @@ from collections.abc import Awaitable, Callable
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
from typing import cast
|
||||
from urllib.parse import quote_plus, urljoin
|
||||
|
||||
from hass_nabucasa import Cloud
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import alexa, google_assistant, http
|
||||
from homeassistant.components.auth import STRICT_CONNECTION_URL
|
||||
from homeassistant.components.http.auth import async_sign_path
|
||||
from homeassistant.components import alexa, google_assistant
|
||||
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_DESCRIPTION,
|
||||
@@ -24,21 +21,8 @@ from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import (
|
||||
Event,
|
||||
HassJob,
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
ServiceResponse,
|
||||
SupportsResponse,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import (
|
||||
HomeAssistantError,
|
||||
ServiceValidationError,
|
||||
Unauthorized,
|
||||
UnknownUser,
|
||||
)
|
||||
from homeassistant.core import Event, HassJob, HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, entityfilter
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.discovery import async_load_platform
|
||||
@@ -47,7 +31,6 @@ from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_send,
|
||||
)
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.network import NoURLAvailableError, get_url
|
||||
from homeassistant.helpers.service import async_register_admin_service
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import bind_hass
|
||||
@@ -418,50 +401,3 @@ def _setup_services(hass: HomeAssistant, prefs: CloudPreferences) -> None:
|
||||
async_register_admin_service(
|
||||
hass, DOMAIN, SERVICE_REMOTE_DISCONNECT, _service_handler
|
||||
)
|
||||
|
||||
async def create_temporary_strict_connection_url(
|
||||
call: ServiceCall,
|
||||
) -> ServiceResponse:
|
||||
"""Create a strict connection url and return it."""
|
||||
# Copied form homeassistant/helpers/service.py#_async_admin_handler
|
||||
# as the helper supports no responses yet
|
||||
if call.context.user_id:
|
||||
user = await hass.auth.async_get_user(call.context.user_id)
|
||||
if user is None:
|
||||
raise UnknownUser(context=call.context)
|
||||
if not user.is_admin:
|
||||
raise Unauthorized(context=call.context)
|
||||
|
||||
if prefs.strict_connection is http.const.StrictConnectionMode.DISABLED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="strict_connection_not_enabled",
|
||||
)
|
||||
|
||||
try:
|
||||
url = get_url(hass, require_cloud=True)
|
||||
except NoURLAvailableError as ex:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_url_available",
|
||||
) from ex
|
||||
|
||||
path = async_sign_path(
|
||||
hass,
|
||||
STRICT_CONNECTION_URL,
|
||||
timedelta(hours=1),
|
||||
use_content_user=True,
|
||||
)
|
||||
url = urljoin(url, path)
|
||||
|
||||
return {
|
||||
"url": f"https://login.home-assistant.io?u={quote_plus(url)}",
|
||||
"direct_url": url,
|
||||
}
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"create_temporary_strict_connection_url",
|
||||
create_temporary_strict_connection_url,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
@@ -250,7 +250,6 @@ class CloudClient(Interface):
|
||||
"enabled": self._prefs.remote_enabled,
|
||||
"instance_domain": self.cloud.remote.instance_domain,
|
||||
"alias": self.cloud.remote.alias,
|
||||
"strict_connection": self._prefs.strict_connection,
|
||||
},
|
||||
"version": HA_VERSION,
|
||||
"instance_id": self.prefs.instance_id,
|
||||
|
@@ -33,7 +33,6 @@ PREF_GOOGLE_SETTINGS_VERSION = "google_settings_version"
|
||||
PREF_TTS_DEFAULT_VOICE = "tts_default_voice"
|
||||
PREF_GOOGLE_CONNECTED = "google_connected"
|
||||
PREF_REMOTE_ALLOW_REMOTE_ENABLE = "remote_allow_remote_enable"
|
||||
PREF_STRICT_CONNECTION = "strict_connection"
|
||||
DEFAULT_TTS_DEFAULT_VOICE = ("en-US", "JennyNeural")
|
||||
DEFAULT_DISABLE_2FA = False
|
||||
DEFAULT_ALEXA_REPORT_STATE = True
|
||||
|
@@ -19,7 +19,7 @@ from hass_nabucasa.const import STATE_DISCONNECTED
|
||||
from hass_nabucasa.voice import TTS_VOICES
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import http, websocket_api
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.alexa import (
|
||||
entities as alexa_entities,
|
||||
errors as alexa_errors,
|
||||
@@ -46,7 +46,6 @@ from .const import (
|
||||
PREF_GOOGLE_REPORT_STATE,
|
||||
PREF_GOOGLE_SECURE_DEVICES_PIN,
|
||||
PREF_REMOTE_ALLOW_REMOTE_ENABLE,
|
||||
PREF_STRICT_CONNECTION,
|
||||
PREF_TTS_DEFAULT_VOICE,
|
||||
REQUEST_TIMEOUT,
|
||||
)
|
||||
@@ -449,9 +448,6 @@ def validate_language_voice(value: tuple[str, str]) -> tuple[str, str]:
|
||||
vol.Coerce(tuple), validate_language_voice
|
||||
),
|
||||
vol.Optional(PREF_REMOTE_ALLOW_REMOTE_ENABLE): bool,
|
||||
vol.Optional(PREF_STRICT_CONNECTION): vol.Coerce(
|
||||
http.const.StrictConnectionMode
|
||||
),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
|
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"services": {
|
||||
"create_temporary_strict_connection_url": "mdi:login-variant",
|
||||
"remote_connect": "mdi:cloud",
|
||||
"remote_disconnect": "mdi:cloud-off"
|
||||
}
|
||||
|
@@ -10,7 +10,7 @@ from hass_nabucasa.voice import MAP_VOICE
|
||||
|
||||
from homeassistant.auth.const import GROUP_ID_ADMIN
|
||||
from homeassistant.auth.models import User
|
||||
from homeassistant.components import http, webhook
|
||||
from homeassistant.components import webhook
|
||||
from homeassistant.components.google_assistant.http import (
|
||||
async_get_users as async_get_google_assistant_users,
|
||||
)
|
||||
@@ -44,7 +44,6 @@ from .const import (
|
||||
PREF_INSTANCE_ID,
|
||||
PREF_REMOTE_ALLOW_REMOTE_ENABLE,
|
||||
PREF_REMOTE_DOMAIN,
|
||||
PREF_STRICT_CONNECTION,
|
||||
PREF_TTS_DEFAULT_VOICE,
|
||||
PREF_USERNAME,
|
||||
)
|
||||
@@ -177,7 +176,6 @@ class CloudPreferences:
|
||||
google_settings_version: int | UndefinedType = UNDEFINED,
|
||||
google_connected: bool | UndefinedType = UNDEFINED,
|
||||
remote_allow_remote_enable: bool | UndefinedType = UNDEFINED,
|
||||
strict_connection: http.const.StrictConnectionMode | UndefinedType = UNDEFINED,
|
||||
) -> None:
|
||||
"""Update user preferences."""
|
||||
prefs = {**self._prefs}
|
||||
@@ -197,7 +195,6 @@ class CloudPreferences:
|
||||
(PREF_REMOTE_DOMAIN, remote_domain),
|
||||
(PREF_GOOGLE_CONNECTED, google_connected),
|
||||
(PREF_REMOTE_ALLOW_REMOTE_ENABLE, remote_allow_remote_enable),
|
||||
(PREF_STRICT_CONNECTION, strict_connection),
|
||||
):
|
||||
if value is not UNDEFINED:
|
||||
prefs[key] = value
|
||||
@@ -245,7 +242,6 @@ class CloudPreferences:
|
||||
PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin,
|
||||
PREF_REMOTE_ALLOW_REMOTE_ENABLE: self.remote_allow_remote_enable,
|
||||
PREF_TTS_DEFAULT_VOICE: self.tts_default_voice,
|
||||
PREF_STRICT_CONNECTION: self.strict_connection,
|
||||
}
|
||||
|
||||
@property
|
||||
@@ -362,20 +358,6 @@ class CloudPreferences:
|
||||
"""
|
||||
return self._prefs.get(PREF_TTS_DEFAULT_VOICE, DEFAULT_TTS_DEFAULT_VOICE) # type: ignore[no-any-return]
|
||||
|
||||
@property
|
||||
def strict_connection(self) -> http.const.StrictConnectionMode:
|
||||
"""Return the strict connection mode."""
|
||||
mode = self._prefs.get(PREF_STRICT_CONNECTION)
|
||||
|
||||
if mode is None:
|
||||
# Set to default value
|
||||
# We store None in the store as the default value to detect if the user has changed the
|
||||
# value or not.
|
||||
mode = http.const.StrictConnectionMode.DISABLED
|
||||
elif not isinstance(mode, http.const.StrictConnectionMode):
|
||||
mode = http.const.StrictConnectionMode(mode)
|
||||
return mode
|
||||
|
||||
async def get_cloud_user(self) -> str:
|
||||
"""Return ID of Home Assistant Cloud system user."""
|
||||
user = await self._load_cloud_user()
|
||||
@@ -433,5 +415,4 @@ class CloudPreferences:
|
||||
PREF_REMOTE_DOMAIN: None,
|
||||
PREF_REMOTE_ALLOW_REMOTE_ENABLE: True,
|
||||
PREF_USERNAME: username,
|
||||
PREF_STRICT_CONNECTION: None,
|
||||
}
|
||||
|
@@ -5,14 +5,6 @@
|
||||
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"strict_connection_not_enabled": {
|
||||
"message": "Strict connection is not enabled for cloud requests"
|
||||
},
|
||||
"no_url_available": {
|
||||
"message": "No cloud URL available.\nPlease mark sure you have a working Remote UI."
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"can_reach_cert_server": "Reach Certificate Server",
|
||||
@@ -81,10 +73,6 @@
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"create_temporary_strict_connection_url": {
|
||||
"name": "Create a temporary strict connection URL",
|
||||
"description": "Create a temporary strict connection URL, which can be used to login on another device."
|
||||
},
|
||||
"remote_connect": {
|
||||
"name": "Remote connect",
|
||||
"description": "Makes the instance UI accessible from outside of the local network by using Home Assistant Cloud."
|
||||
|
@@ -1,15 +0,0 @@
|
||||
"""Cloud util functions."""
|
||||
|
||||
from hass_nabucasa import Cloud
|
||||
|
||||
from homeassistant.components import http
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .client import CloudClient
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
def get_strict_connection_mode(hass: HomeAssistant) -> http.const.StrictConnectionMode:
|
||||
"""Get the strict connection mode."""
|
||||
cloud: Cloud[CloudClient] = hass.data[DOMAIN]
|
||||
return cloud.client.prefs.strict_connection
|
@@ -268,7 +268,7 @@ WALLETS = {
|
||||
"XTZ": "XTZ",
|
||||
"YER": "YER",
|
||||
"YFI": "YFI",
|
||||
"ZAR": "ZAR",
|
||||
"ZAR": "ZAR", # codespell:ignore zar
|
||||
"ZEC": "ZEC",
|
||||
"ZMW": "ZMW",
|
||||
"ZRX": "ZRX",
|
||||
@@ -550,7 +550,7 @@ RATES = {
|
||||
"TRAC": "TRAC",
|
||||
"TRB": "TRB",
|
||||
"TRIBE": "TRIBE",
|
||||
"TRU": "TRU",
|
||||
"TRU": "TRU", # codespell:ignore tru
|
||||
"TRY": "TRY",
|
||||
"TTD": "TTD",
|
||||
"TWD": "TWD",
|
||||
@@ -590,7 +590,7 @@ RATES = {
|
||||
"YER": "YER",
|
||||
"YFI": "YFI",
|
||||
"YFII": "YFII",
|
||||
"ZAR": "ZAR",
|
||||
"ZAR": "ZAR", # codespell:ignore zar
|
||||
"ZEC": "ZEC",
|
||||
"ZEN": "ZEN",
|
||||
"ZMW": "ZMW",
|
||||
|
@@ -87,6 +87,7 @@ async def daikin_api_setup(
|
||||
device = await Appliance.factory(
|
||||
host, session, key=key, uuid=uuid, password=password
|
||||
)
|
||||
_LOGGER.debug("Connection to %s successful", host)
|
||||
except TimeoutError as err:
|
||||
_LOGGER.debug("Connection to %s timed out", host)
|
||||
raise ConfigEntryNotReady from err
|
||||
|
@@ -51,6 +51,11 @@
|
||||
"compressor_energy_consumption": {
|
||||
"name": "Compressor energy consumption"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"toggle": {
|
||||
"name": "Power"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -347,7 +347,8 @@ AQARA_SINGLE_WALL_SWITCH = {
|
||||
(CONF_DOUBLE_PRESS, CONF_TURN_ON): {CONF_EVENT: 1004},
|
||||
}
|
||||
|
||||
AQARA_MINI_SWITCH_MODEL = "lumi.remote.b1acn01"
|
||||
AQARA_MINI_SWITCH_WXKG11LM_MODEL = "lumi.remote.b1acn01"
|
||||
AQARA_MINI_SWITCH_WBR02D_MODEL = "lumi.remote.b1acn02"
|
||||
AQARA_MINI_SWITCH = {
|
||||
(CONF_SHORT_PRESS, CONF_TURN_ON): {CONF_EVENT: 1002},
|
||||
(CONF_DOUBLE_PRESS, CONF_TURN_ON): {CONF_EVENT: 1004},
|
||||
@@ -615,7 +616,8 @@ REMOTES = {
|
||||
AQARA_SINGLE_WALL_SWITCH_QBKG11LM_MODEL: AQARA_SINGLE_WALL_SWITCH_QBKG11LM,
|
||||
AQARA_SINGLE_WALL_SWITCH_WXKG03LM_MODEL: AQARA_SINGLE_WALL_SWITCH,
|
||||
AQARA_SINGLE_WALL_SWITCH_WXKG06LM_MODEL: AQARA_SINGLE_WALL_SWITCH,
|
||||
AQARA_MINI_SWITCH_MODEL: AQARA_MINI_SWITCH,
|
||||
AQARA_MINI_SWITCH_WXKG11LM_MODEL: AQARA_MINI_SWITCH,
|
||||
AQARA_MINI_SWITCH_WBR02D_MODEL: AQARA_MINI_SWITCH,
|
||||
AQARA_ROUND_SWITCH_MODEL: AQARA_ROUND_SWITCH,
|
||||
AQARA_SQUARE_SWITCH_MODEL: AQARA_SQUARE_SWITCH,
|
||||
AQARA_SQUARE_SWITCH_WXKG11LM_2016_MODEL: AQARA_SQUARE_SWITCH_WXKG11LM_2016,
|
||||
|
@@ -62,7 +62,7 @@ async def async_setup_entry(
|
||||
await hass.async_add_executor_job(
|
||||
partial(
|
||||
HomeControl,
|
||||
gateway_id=gateway_id,
|
||||
gateway_id=str(gateway_id),
|
||||
mydevolo_instance=mydevolo,
|
||||
zeroconf_instance=zeroconf_instance,
|
||||
)
|
||||
|
@@ -3,9 +3,9 @@
|
||||
Data is fetched from DWD:
|
||||
https://rcccm.dwd.de/DE/wetter/warnungen_aktuell/objekt_einbindung/objekteinbindung.html
|
||||
|
||||
Warnungen vor extremem Unwetter (Stufe 4)
|
||||
Warnungen vor extremem Unwetter (Stufe 4) # codespell:ignore vor
|
||||
Unwetterwarnungen (Stufe 3)
|
||||
Warnungen vor markantem Wetter (Stufe 2)
|
||||
Warnungen vor markantem Wetter (Stufe 2) # codespell:ignore vor
|
||||
Wetterwarnungen (Stufe 1)
|
||||
"""
|
||||
|
||||
|
@@ -17,6 +17,8 @@ SUPPORTED_LIFESPANS = (
|
||||
LifeSpan.FILTER,
|
||||
LifeSpan.LENS_BRUSH,
|
||||
LifeSpan.SIDE_BRUSH,
|
||||
LifeSpan.UNIT_CARE,
|
||||
LifeSpan.ROUND_MOP,
|
||||
)
|
||||
|
||||
|
||||
|
@@ -26,6 +26,12 @@
|
||||
},
|
||||
"reset_lifespan_side_brush": {
|
||||
"default": "mdi:broom"
|
||||
},
|
||||
"reset_lifespan_unit_care": {
|
||||
"default": "mdi:robot-vacuum"
|
||||
},
|
||||
"reset_lifespan_round_mop": {
|
||||
"default": "mdi:broom"
|
||||
}
|
||||
},
|
||||
"event": {
|
||||
@@ -63,6 +69,12 @@
|
||||
"lifespan_side_brush": {
|
||||
"default": "mdi:broom"
|
||||
},
|
||||
"lifespan_unit_care": {
|
||||
"default": "mdi:robot-vacuum"
|
||||
},
|
||||
"lifespan_round_mop": {
|
||||
"default": "mdi:broom"
|
||||
},
|
||||
"network_ip": {
|
||||
"default": "mdi:ip-network-outline"
|
||||
},
|
||||
|
@@ -58,6 +58,12 @@
|
||||
"reset_lifespan_lens_brush": {
|
||||
"name": "Reset lens brush lifespan"
|
||||
},
|
||||
"reset_lifespan_round_mop": {
|
||||
"name": "Reset round mop lifespan"
|
||||
},
|
||||
"reset_lifespan_unit_care": {
|
||||
"name": "Reset unit care lifespan"
|
||||
},
|
||||
"reset_lifespan_side_brush": {
|
||||
"name": "Reset side brushes lifespan"
|
||||
}
|
||||
@@ -113,6 +119,12 @@
|
||||
"lifespan_side_brush": {
|
||||
"name": "Side brushes lifespan"
|
||||
},
|
||||
"lifespan_unit_care": {
|
||||
"name": "Unit care lifespan"
|
||||
},
|
||||
"lifespan_round_mop": {
|
||||
"name": "Round mop lifespan"
|
||||
},
|
||||
"network_ip": {
|
||||
"name": "IP address"
|
||||
},
|
||||
|
@@ -26,7 +26,7 @@ async def async_get_device_diagnostics(
|
||||
"device": {
|
||||
"name": station.station,
|
||||
"model": station.model,
|
||||
"frequency": station.frequence,
|
||||
"frequency": station.frequence, # codespell:ignore frequence
|
||||
"version": station.version,
|
||||
},
|
||||
"raw": ecowitt.last_values[station_id],
|
||||
|
@@ -20,7 +20,7 @@ from . import data
|
||||
from .const import DOMAIN
|
||||
|
||||
ENERGY_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.ENERGY,)
|
||||
ENERGY_USAGE_UNITS = {
|
||||
ENERGY_USAGE_UNITS: dict[str, tuple[UnitOfEnergy, ...]] = {
|
||||
sensor.SensorDeviceClass.ENERGY: (
|
||||
UnitOfEnergy.GIGA_JOULE,
|
||||
UnitOfEnergy.KILO_WATT_HOUR,
|
||||
@@ -38,7 +38,7 @@ GAS_USAGE_DEVICE_CLASSES = (
|
||||
sensor.SensorDeviceClass.ENERGY,
|
||||
sensor.SensorDeviceClass.GAS,
|
||||
)
|
||||
GAS_USAGE_UNITS = {
|
||||
GAS_USAGE_UNITS: dict[str, tuple[UnitOfEnergy | UnitOfVolume, ...]] = {
|
||||
sensor.SensorDeviceClass.ENERGY: (
|
||||
UnitOfEnergy.GIGA_JOULE,
|
||||
UnitOfEnergy.KILO_WATT_HOUR,
|
||||
@@ -58,7 +58,7 @@ GAS_PRICE_UNITS = tuple(
|
||||
GAS_UNIT_ERROR = "entity_unexpected_unit_gas"
|
||||
GAS_PRICE_UNIT_ERROR = "entity_unexpected_unit_gas_price"
|
||||
WATER_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.WATER,)
|
||||
WATER_USAGE_UNITS = {
|
||||
WATER_USAGE_UNITS: dict[str, tuple[UnitOfVolume, ...]] = {
|
||||
sensor.SensorDeviceClass.WATER: (
|
||||
UnitOfVolume.CENTUM_CUBIC_FEET,
|
||||
UnitOfVolume.CUBIC_FEET,
|
||||
@@ -360,12 +360,14 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
elif flow.get("entity_energy_price") is not None:
|
||||
elif (
|
||||
entity_energy_price := flow.get("entity_energy_price")
|
||||
) is not None:
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_price_entity,
|
||||
hass,
|
||||
flow["entity_energy_price"],
|
||||
entity_energy_price,
|
||||
source_result,
|
||||
ENERGY_PRICE_UNITS,
|
||||
ENERGY_PRICE_UNIT_ERROR,
|
||||
@@ -411,12 +413,14 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
elif flow.get("entity_energy_price") is not None:
|
||||
elif (
|
||||
entity_energy_price := flow.get("entity_energy_price")
|
||||
) is not None:
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_price_entity,
|
||||
hass,
|
||||
flow["entity_energy_price"],
|
||||
entity_energy_price,
|
||||
source_result,
|
||||
ENERGY_PRICE_UNITS,
|
||||
ENERGY_PRICE_UNIT_ERROR,
|
||||
@@ -462,12 +466,12 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
elif source.get("entity_energy_price") is not None:
|
||||
elif (entity_energy_price := source.get("entity_energy_price")) is not None:
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_price_entity,
|
||||
hass,
|
||||
source["entity_energy_price"],
|
||||
entity_energy_price,
|
||||
source_result,
|
||||
GAS_PRICE_UNITS,
|
||||
GAS_PRICE_UNIT_ERROR,
|
||||
@@ -513,12 +517,12 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
elif source.get("entity_energy_price") is not None:
|
||||
elif (entity_energy_price := source.get("entity_energy_price")) is not None:
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_price_entity,
|
||||
hass,
|
||||
source["entity_energy_price"],
|
||||
entity_energy_price,
|
||||
source_result,
|
||||
WATER_PRICE_UNITS,
|
||||
WATER_PRICE_UNIT_ERROR,
|
||||
|
@@ -44,7 +44,7 @@ class FibaroLock(FibaroDevice, LockEntity):
|
||||
|
||||
def unlock(self, **kwargs: Any) -> None:
|
||||
"""Unlock the device."""
|
||||
self.action("unsecure")
|
||||
self.action("unsecure") # codespell:ignore unsecure
|
||||
self._attr_is_locked = False
|
||||
|
||||
def update(self) -> None:
|
||||
|
@@ -72,5 +72,5 @@ class FreeboxSwitch(SwitchEntity):
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Get the state and update it."""
|
||||
datas = await self._router.wifi.get_global_config()
|
||||
self._attr_is_on = bool(datas["enabled"])
|
||||
data = await self._router.wifi.get_global_config()
|
||||
self._attr_is_on = bool(data["enabled"])
|
||||
|
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from fyta_cli.fyta_connector import FytaConnector
|
||||
|
||||
@@ -17,6 +16,7 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util.dt import async_get_time_zone
|
||||
|
||||
from .const import CONF_EXPIRATION, DOMAIN
|
||||
from .coordinator import FytaCoordinator
|
||||
@@ -37,7 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
access_token: str = entry.data[CONF_ACCESS_TOKEN]
|
||||
expiration: datetime = datetime.fromisoformat(
|
||||
entry.data[CONF_EXPIRATION]
|
||||
).astimezone(ZoneInfo(tz))
|
||||
).astimezone(await async_get_time_zone(tz))
|
||||
|
||||
fyta = FytaConnector(username, password, access_token, expiration, tz)
|
||||
|
||||
|
@@ -15,7 +15,10 @@ from .helpers import async_send_text_commands, default_language_code
|
||||
# https://support.google.com/assistant/answer/9071582?hl=en
|
||||
LANG_TO_BROADCAST_COMMAND = {
|
||||
"en": ("broadcast {0}", "broadcast to {1} {0}"),
|
||||
"de": ("Nachricht an alle {0}", "Nachricht an alle an {1} {0}"),
|
||||
"de": (
|
||||
"Nachricht an alle {0}", # codespell:ignore alle
|
||||
"Nachricht an alle an {1} {0}", # codespell:ignore alle
|
||||
),
|
||||
"es": ("Anuncia {0}", "Anuncia en {1} {0}"),
|
||||
"fr": ("Diffuse {0}", "Diffuse dans {1} {0}"),
|
||||
"it": ("Trasmetti {0}", "Trasmetti in {1} {0}"),
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from asyncio import timeout
|
||||
from functools import partial
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
@@ -100,9 +101,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
genai.configure(api_key=entry.data[CONF_API_KEY])
|
||||
|
||||
try:
|
||||
await hass.async_add_executor_job(partial(genai.list_models))
|
||||
except ClientError as err:
|
||||
if err.reason == "API_KEY_INVALID":
|
||||
async with timeout(5.0):
|
||||
next(await hass.async_add_executor_job(partial(genai.list_models)), None)
|
||||
except (ClientError, TimeoutError) as err:
|
||||
if isinstance(err, ClientError) and err.reason == "API_KEY_INVALID":
|
||||
LOGGER.error("Invalid API key: %s", err)
|
||||
return False
|
||||
raise ConfigEntryNotReady(err) from err
|
||||
|
@@ -32,18 +32,24 @@ from homeassistant.helpers.selector import (
|
||||
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
CONF_DANGEROUS_BLOCK_THRESHOLD,
|
||||
CONF_HARASSMENT_BLOCK_THRESHOLD,
|
||||
CONF_HATE_BLOCK_THRESHOLD,
|
||||
CONF_MAX_TOKENS,
|
||||
CONF_PROMPT,
|
||||
CONF_RECOMMENDED,
|
||||
CONF_SEXUAL_BLOCK_THRESHOLD,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_TOP_K,
|
||||
CONF_TOP_P,
|
||||
DEFAULT_CHAT_MODEL,
|
||||
DEFAULT_MAX_TOKENS,
|
||||
DEFAULT_PROMPT,
|
||||
DEFAULT_TEMPERATURE,
|
||||
DEFAULT_TOP_K,
|
||||
DEFAULT_TOP_P,
|
||||
DOMAIN,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
RECOMMENDED_HARM_BLOCK_THRESHOLD,
|
||||
RECOMMENDED_MAX_TOKENS,
|
||||
RECOMMENDED_TEMPERATURE,
|
||||
RECOMMENDED_TOP_K,
|
||||
RECOMMENDED_TOP_P,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -54,6 +60,12 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
RECOMMENDED_OPTIONS = {
|
||||
CONF_RECOMMENDED: True,
|
||||
CONF_LLM_HASS_API: llm.LLM_API_ASSIST,
|
||||
CONF_PROMPT: DEFAULT_PROMPT,
|
||||
}
|
||||
|
||||
|
||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
|
||||
"""Validate the user input allows us to connect.
|
||||
@@ -94,7 +106,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_create_entry(
|
||||
title="Google Generative AI",
|
||||
data=user_input,
|
||||
options={CONF_LLM_HASS_API: llm.LLM_API_ASSIST},
|
||||
options=RECOMMENDED_OPTIONS,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
@@ -115,18 +127,32 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow):
|
||||
def __init__(self, config_entry: ConfigEntry) -> None:
|
||||
"""Initialize options flow."""
|
||||
self.config_entry = config_entry
|
||||
self.last_rendered_recommended = config_entry.options.get(
|
||||
CONF_RECOMMENDED, False
|
||||
)
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Manage the options."""
|
||||
options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options
|
||||
|
||||
if user_input is not None:
|
||||
if user_input[CONF_LLM_HASS_API] == "none":
|
||||
user_input.pop(CONF_LLM_HASS_API)
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
schema = await google_generative_ai_config_option_schema(
|
||||
self.hass, self.config_entry.options
|
||||
)
|
||||
if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended:
|
||||
if user_input[CONF_LLM_HASS_API] == "none":
|
||||
user_input.pop(CONF_LLM_HASS_API)
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
|
||||
# Re-render the options again, now with the recommended options shown/hidden
|
||||
self.last_rendered_recommended = user_input[CONF_RECOMMENDED]
|
||||
|
||||
options = {
|
||||
CONF_RECOMMENDED: user_input[CONF_RECOMMENDED],
|
||||
CONF_PROMPT: user_input[CONF_PROMPT],
|
||||
CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API],
|
||||
}
|
||||
|
||||
schema = await google_generative_ai_config_option_schema(self.hass, options)
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=vol.Schema(schema),
|
||||
@@ -135,41 +161,16 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow):
|
||||
|
||||
async def google_generative_ai_config_option_schema(
|
||||
hass: HomeAssistant,
|
||||
options: MappingProxyType[str, Any],
|
||||
options: dict[str, Any] | MappingProxyType[str, Any],
|
||||
) -> dict:
|
||||
"""Return a schema for Google Generative AI completion options."""
|
||||
api_models = await hass.async_add_executor_job(partial(genai.list_models))
|
||||
|
||||
models: list[SelectOptionDict] = [
|
||||
SelectOptionDict(
|
||||
label="Gemini 1.5 Flash (recommended)",
|
||||
value="models/gemini-1.5-flash-latest",
|
||||
),
|
||||
]
|
||||
models.extend(
|
||||
SelectOptionDict(
|
||||
label=api_model.display_name,
|
||||
value=api_model.name,
|
||||
)
|
||||
for api_model in sorted(api_models, key=lambda x: x.display_name)
|
||||
if (
|
||||
api_model.name
|
||||
not in (
|
||||
"models/gemini-1.0-pro", # duplicate of gemini-pro
|
||||
"models/gemini-1.5-flash-latest",
|
||||
)
|
||||
and "vision" not in api_model.name
|
||||
and "generateContent" in api_model.supported_generation_methods
|
||||
)
|
||||
)
|
||||
|
||||
apis: list[SelectOptionDict] = [
|
||||
hass_apis: list[SelectOptionDict] = [
|
||||
SelectOptionDict(
|
||||
label="No control",
|
||||
value="none",
|
||||
)
|
||||
]
|
||||
apis.extend(
|
||||
hass_apis.extend(
|
||||
SelectOptionDict(
|
||||
label=api.name,
|
||||
value=api.id,
|
||||
@@ -177,45 +178,119 @@ async def google_generative_ai_config_option_schema(
|
||||
for api in llm.async_get_apis(hass)
|
||||
)
|
||||
|
||||
return {
|
||||
vol.Optional(
|
||||
CONF_CHAT_MODEL,
|
||||
description={"suggested_value": options.get(CONF_CHAT_MODEL)},
|
||||
default=DEFAULT_CHAT_MODEL,
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
options=models,
|
||||
)
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_LLM_HASS_API,
|
||||
description={"suggested_value": options.get(CONF_LLM_HASS_API)},
|
||||
default="none",
|
||||
): SelectSelector(SelectSelectorConfig(options=apis)),
|
||||
schema = {
|
||||
vol.Optional(
|
||||
CONF_PROMPT,
|
||||
description={"suggested_value": options.get(CONF_PROMPT)},
|
||||
default=DEFAULT_PROMPT,
|
||||
): TemplateSelector(),
|
||||
vol.Optional(
|
||||
CONF_TEMPERATURE,
|
||||
description={"suggested_value": options.get(CONF_TEMPERATURE)},
|
||||
default=DEFAULT_TEMPERATURE,
|
||||
): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
|
||||
vol.Optional(
|
||||
CONF_TOP_P,
|
||||
description={"suggested_value": options.get(CONF_TOP_P)},
|
||||
default=DEFAULT_TOP_P,
|
||||
): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
|
||||
vol.Optional(
|
||||
CONF_TOP_K,
|
||||
description={"suggested_value": options.get(CONF_TOP_K)},
|
||||
default=DEFAULT_TOP_K,
|
||||
): int,
|
||||
vol.Optional(
|
||||
CONF_MAX_TOKENS,
|
||||
description={"suggested_value": options.get(CONF_MAX_TOKENS)},
|
||||
default=DEFAULT_MAX_TOKENS,
|
||||
): int,
|
||||
CONF_LLM_HASS_API,
|
||||
description={"suggested_value": options.get(CONF_LLM_HASS_API)},
|
||||
default="none",
|
||||
): SelectSelector(SelectSelectorConfig(options=hass_apis)),
|
||||
vol.Required(
|
||||
CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)
|
||||
): bool,
|
||||
}
|
||||
|
||||
if options.get(CONF_RECOMMENDED):
|
||||
return schema
|
||||
|
||||
api_models = await hass.async_add_executor_job(partial(genai.list_models))
|
||||
|
||||
models = [
|
||||
SelectOptionDict(
|
||||
label=api_model.display_name,
|
||||
value=api_model.name,
|
||||
)
|
||||
for api_model in sorted(api_models, key=lambda x: x.display_name)
|
||||
if (
|
||||
api_model.name != "models/gemini-1.0-pro" # duplicate of gemini-pro
|
||||
and "vision" not in api_model.name
|
||||
and "generateContent" in api_model.supported_generation_methods
|
||||
)
|
||||
]
|
||||
|
||||
harm_block_thresholds: list[SelectOptionDict] = [
|
||||
SelectOptionDict(
|
||||
label="Block none",
|
||||
value="BLOCK_NONE",
|
||||
),
|
||||
SelectOptionDict(
|
||||
label="Block few",
|
||||
value="BLOCK_ONLY_HIGH",
|
||||
),
|
||||
SelectOptionDict(
|
||||
label="Block some",
|
||||
value="BLOCK_MEDIUM_AND_ABOVE",
|
||||
),
|
||||
SelectOptionDict(
|
||||
label="Block most",
|
||||
value="BLOCK_LOW_AND_ABOVE",
|
||||
),
|
||||
]
|
||||
harm_block_thresholds_selector = SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
mode=SelectSelectorMode.DROPDOWN, options=harm_block_thresholds
|
||||
)
|
||||
)
|
||||
|
||||
schema.update(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_CHAT_MODEL,
|
||||
description={"suggested_value": options.get(CONF_CHAT_MODEL)},
|
||||
default=RECOMMENDED_CHAT_MODEL,
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(mode=SelectSelectorMode.DROPDOWN, options=models)
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_TEMPERATURE,
|
||||
description={"suggested_value": options.get(CONF_TEMPERATURE)},
|
||||
default=RECOMMENDED_TEMPERATURE,
|
||||
): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
|
||||
vol.Optional(
|
||||
CONF_TOP_P,
|
||||
description={"suggested_value": options.get(CONF_TOP_P)},
|
||||
default=RECOMMENDED_TOP_P,
|
||||
): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
|
||||
vol.Optional(
|
||||
CONF_TOP_K,
|
||||
description={"suggested_value": options.get(CONF_TOP_K)},
|
||||
default=RECOMMENDED_TOP_K,
|
||||
): int,
|
||||
vol.Optional(
|
||||
CONF_MAX_TOKENS,
|
||||
description={"suggested_value": options.get(CONF_MAX_TOKENS)},
|
||||
default=RECOMMENDED_MAX_TOKENS,
|
||||
): int,
|
||||
vol.Optional(
|
||||
CONF_HARASSMENT_BLOCK_THRESHOLD,
|
||||
description={
|
||||
"suggested_value": options.get(CONF_HARASSMENT_BLOCK_THRESHOLD)
|
||||
},
|
||||
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
|
||||
): harm_block_thresholds_selector,
|
||||
vol.Optional(
|
||||
CONF_HATE_BLOCK_THRESHOLD,
|
||||
description={"suggested_value": options.get(CONF_HATE_BLOCK_THRESHOLD)},
|
||||
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
|
||||
): harm_block_thresholds_selector,
|
||||
vol.Optional(
|
||||
CONF_SEXUAL_BLOCK_THRESHOLD,
|
||||
description={
|
||||
"suggested_value": options.get(CONF_SEXUAL_BLOCK_THRESHOLD)
|
||||
},
|
||||
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
|
||||
): harm_block_thresholds_selector,
|
||||
vol.Optional(
|
||||
CONF_DANGEROUS_BLOCK_THRESHOLD,
|
||||
description={
|
||||
"suggested_value": options.get(CONF_DANGEROUS_BLOCK_THRESHOLD)
|
||||
},
|
||||
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
|
||||
): harm_block_thresholds_selector,
|
||||
}
|
||||
)
|
||||
return schema
|
||||
|
@@ -5,32 +5,21 @@ import logging
|
||||
DOMAIN = "google_generative_ai_conversation"
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
CONF_PROMPT = "prompt"
|
||||
DEFAULT_PROMPT = """This smart home is controlled by Home Assistant.
|
||||
|
||||
An overview of the areas and the devices in this smart home:
|
||||
{%- for area in areas() %}
|
||||
{%- set area_info = namespace(printed=false) %}
|
||||
{%- for device in area_devices(area) -%}
|
||||
{%- if not device_attr(device, "disabled_by") and not device_attr(device, "entry_type") and device_attr(device, "name") %}
|
||||
{%- if not area_info.printed %}
|
||||
|
||||
{{ area_name(area) }}:
|
||||
{%- set area_info.printed = true %}
|
||||
{%- endif %}
|
||||
- {{ device_attr(device, "name") }}{% if device_attr(device, "model") and (device_attr(device, "model") | string) not in (device_attr(device, "name") | string) %} ({{ device_attr(device, "model") }}){% endif %}
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- endfor %}
|
||||
"""
|
||||
DEFAULT_PROMPT = "Answer in plain text. Keep it simple and to the point."
|
||||
|
||||
CONF_RECOMMENDED = "recommended"
|
||||
CONF_CHAT_MODEL = "chat_model"
|
||||
DEFAULT_CHAT_MODEL = "models/gemini-pro"
|
||||
RECOMMENDED_CHAT_MODEL = "models/gemini-1.5-flash-latest"
|
||||
CONF_TEMPERATURE = "temperature"
|
||||
DEFAULT_TEMPERATURE = 0.9
|
||||
RECOMMENDED_TEMPERATURE = 1.0
|
||||
CONF_TOP_P = "top_p"
|
||||
DEFAULT_TOP_P = 1.0
|
||||
RECOMMENDED_TOP_P = 0.95
|
||||
CONF_TOP_K = "top_k"
|
||||
DEFAULT_TOP_K = 1
|
||||
RECOMMENDED_TOP_K = 64
|
||||
CONF_MAX_TOKENS = "max_tokens"
|
||||
DEFAULT_MAX_TOKENS = 150
|
||||
DEFAULT_ALLOW_HASS_ACCESS = False
|
||||
RECOMMENDED_MAX_TOKENS = 150
|
||||
CONF_HARASSMENT_BLOCK_THRESHOLD = "harassment_block_threshold"
|
||||
CONF_HATE_BLOCK_THRESHOLD = "hate_block_threshold"
|
||||
CONF_SEXUAL_BLOCK_THRESHOLD = "sexual_block_threshold"
|
||||
CONF_DANGEROUS_BLOCK_THRESHOLD = "dangerous_block_threshold"
|
||||
RECOMMENDED_HARM_BLOCK_THRESHOLD = "BLOCK_LOW_AND_ABOVE"
|
||||
|
@@ -16,25 +16,30 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError, TemplateError
|
||||
from homeassistant.helpers import intent, llm, template
|
||||
from homeassistant.helpers import device_registry as dr, intent, llm, template
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.util import ulid
|
||||
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
CONF_DANGEROUS_BLOCK_THRESHOLD,
|
||||
CONF_HARASSMENT_BLOCK_THRESHOLD,
|
||||
CONF_HATE_BLOCK_THRESHOLD,
|
||||
CONF_MAX_TOKENS,
|
||||
CONF_PROMPT,
|
||||
CONF_SEXUAL_BLOCK_THRESHOLD,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_TOP_K,
|
||||
CONF_TOP_P,
|
||||
DEFAULT_CHAT_MODEL,
|
||||
DEFAULT_MAX_TOKENS,
|
||||
DEFAULT_PROMPT,
|
||||
DEFAULT_TEMPERATURE,
|
||||
DEFAULT_TOP_K,
|
||||
DEFAULT_TOP_P,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
RECOMMENDED_HARM_BLOCK_THRESHOLD,
|
||||
RECOMMENDED_MAX_TOKENS,
|
||||
RECOMMENDED_TEMPERATURE,
|
||||
RECOMMENDED_TOP_K,
|
||||
RECOMMENDED_TOP_P,
|
||||
)
|
||||
|
||||
# Max number of back and forth with the LLM to generate a response
|
||||
@@ -106,13 +111,20 @@ class GoogleGenerativeAIConversationEntity(
|
||||
"""Google Generative AI conversation agent."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
|
||||
def __init__(self, entry: ConfigEntry) -> None:
|
||||
"""Initialize the agent."""
|
||||
self.entry = entry
|
||||
self.history: dict[str, list[genai_types.ContentType]] = {}
|
||||
self._attr_name = entry.title
|
||||
self._attr_unique_id = entry.entry_id
|
||||
self._attr_device_info = dr.DeviceInfo(
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
name=entry.title,
|
||||
manufacturer="Google",
|
||||
model="Generative AI",
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
@property
|
||||
def supported_languages(self) -> list[str] | Literal["*"]:
|
||||
@@ -156,17 +168,30 @@ class GoogleGenerativeAIConversationEntity(
|
||||
)
|
||||
tools = [_format_tool(tool) for tool in llm_api.async_get_tools()]
|
||||
|
||||
raw_prompt = self.entry.options.get(CONF_PROMPT, DEFAULT_PROMPT)
|
||||
model = genai.GenerativeModel(
|
||||
model_name=self.entry.options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL),
|
||||
model_name=self.entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
|
||||
generation_config={
|
||||
"temperature": self.entry.options.get(
|
||||
CONF_TEMPERATURE, DEFAULT_TEMPERATURE
|
||||
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
|
||||
),
|
||||
"top_p": self.entry.options.get(CONF_TOP_P, DEFAULT_TOP_P),
|
||||
"top_k": self.entry.options.get(CONF_TOP_K, DEFAULT_TOP_K),
|
||||
"top_p": self.entry.options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
|
||||
"top_k": self.entry.options.get(CONF_TOP_K, RECOMMENDED_TOP_K),
|
||||
"max_output_tokens": self.entry.options.get(
|
||||
CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS
|
||||
CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS
|
||||
),
|
||||
},
|
||||
safety_settings={
|
||||
"HARASSMENT": self.entry.options.get(
|
||||
CONF_HARASSMENT_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
|
||||
),
|
||||
"HATE": self.entry.options.get(
|
||||
CONF_HATE_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
|
||||
),
|
||||
"SEXUAL": self.entry.options.get(
|
||||
CONF_SEXUAL_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
|
||||
),
|
||||
"DANGEROUS": self.entry.options.get(
|
||||
CONF_DANGEROUS_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
|
||||
),
|
||||
},
|
||||
tools=tools or None,
|
||||
@@ -180,7 +205,31 @@ class GoogleGenerativeAIConversationEntity(
|
||||
messages = [{}, {}]
|
||||
|
||||
try:
|
||||
prompt = self._async_generate_prompt(raw_prompt, llm_api)
|
||||
prompt = template.Template(
|
||||
self.entry.options.get(CONF_PROMPT, DEFAULT_PROMPT), self.hass
|
||||
).async_render(
|
||||
{
|
||||
"ha_name": self.hass.config.location_name,
|
||||
},
|
||||
parse_result=False,
|
||||
)
|
||||
|
||||
if llm_api:
|
||||
empty_tool_input = llm.ToolInput(
|
||||
tool_name="",
|
||||
tool_args={},
|
||||
platform=DOMAIN,
|
||||
context=user_input.context,
|
||||
user_prompt=user_input.text,
|
||||
language=user_input.language,
|
||||
assistant=conversation.DOMAIN,
|
||||
device_id=user_input.device_id,
|
||||
)
|
||||
|
||||
prompt = (
|
||||
await llm_api.async_get_api_prompt(empty_tool_input) + "\n" + prompt
|
||||
)
|
||||
|
||||
except TemplateError as err:
|
||||
LOGGER.error("Error rendering prompt: %s", err)
|
||||
intent_response.async_set_error(
|
||||
@@ -221,7 +270,7 @@ class GoogleGenerativeAIConversationEntity(
|
||||
if not chat_response.parts:
|
||||
intent_response.async_set_error(
|
||||
intent.IntentResponseErrorCode.UNKNOWN,
|
||||
"Sorry, I had a problem talking to Google Generative AI. Likely blocked",
|
||||
"Sorry, I had a problem getting a response from Google Generative AI.",
|
||||
)
|
||||
return conversation.ConversationResult(
|
||||
response=intent_response, conversation_id=conversation_id
|
||||
@@ -267,18 +316,3 @@ class GoogleGenerativeAIConversationEntity(
|
||||
return conversation.ConversationResult(
|
||||
response=intent_response, conversation_id=conversation_id
|
||||
)
|
||||
|
||||
def _async_generate_prompt(self, raw_prompt: str, llm_api: llm.API | None) -> str:
|
||||
"""Generate a prompt for the user."""
|
||||
raw_prompt += "\n"
|
||||
if llm_api:
|
||||
raw_prompt += llm_api.prompt_template
|
||||
else:
|
||||
raw_prompt += llm.PROMPT_NO_API_CONFIGURED
|
||||
|
||||
return template.Template(raw_prompt, self.hass).async_render(
|
||||
{
|
||||
"ha_name": self.hass.config.location_name,
|
||||
},
|
||||
parse_result=False,
|
||||
)
|
||||
|
@@ -18,13 +18,21 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"prompt": "Prompt Template",
|
||||
"recommended": "Recommended model settings",
|
||||
"prompt": "Instructions",
|
||||
"chat_model": "[%key:common::generic::model%]",
|
||||
"temperature": "Temperature",
|
||||
"top_p": "Top P",
|
||||
"top_k": "Top K",
|
||||
"max_tokens": "Maximum tokens to return in response",
|
||||
"llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]"
|
||||
"llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]",
|
||||
"harassment_block_threshold": "Negative or harmful comments targeting identity and/or protected attributes",
|
||||
"hate_block_threshold": "Content that is rude, disrespectful, or profane",
|
||||
"sexual_block_threshold": "Contains references to sexual acts or other lewd content",
|
||||
"dangerous_block_threshold": "Promotes, facilitates, or encourages harmful acts"
|
||||
},
|
||||
"data_description": {
|
||||
"prompt": "Instruct how the LLM should respond. This can be a template."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -81,7 +81,7 @@ SUPPORT_LANGUAGES = [
|
||||
"sv",
|
||||
"sw",
|
||||
"ta",
|
||||
"te",
|
||||
"te", # codespell:ignore te
|
||||
"th",
|
||||
"tl",
|
||||
"tr",
|
||||
|
@@ -67,7 +67,7 @@ ALL_LANGUAGES = [
|
||||
"sr",
|
||||
"sv",
|
||||
"ta",
|
||||
"te",
|
||||
"te", # codespell:ignore te
|
||||
"th",
|
||||
"tl",
|
||||
"tr",
|
||||
|
@@ -3,6 +3,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
from errno import EADDRINUSE
|
||||
import logging
|
||||
|
||||
from govee_local_api.controller import LISTENING_PORT
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
@@ -14,14 +19,29 @@ from .coordinator import GoveeLocalApiCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.LIGHT]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Govee light local from a config entry."""
|
||||
|
||||
coordinator: GoveeLocalApiCoordinator = GoveeLocalApiCoordinator(hass=hass)
|
||||
entry.async_on_unload(coordinator.cleanup)
|
||||
|
||||
await coordinator.start()
|
||||
async def await_cleanup():
|
||||
cleanup_complete: asyncio.Event = coordinator.cleanup()
|
||||
with suppress(TimeoutError):
|
||||
await asyncio.wait_for(cleanup_complete.wait(), 1)
|
||||
|
||||
entry.async_on_unload(await_cleanup)
|
||||
|
||||
try:
|
||||
await coordinator.start()
|
||||
except OSError as ex:
|
||||
if ex.errno != EADDRINUSE:
|
||||
_LOGGER.error("Start failed, errno: %d", ex.errno)
|
||||
return False
|
||||
_LOGGER.error("Port %s already in use", LISTENING_PORT)
|
||||
raise ConfigEntryNotReady from ex
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
|
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
|
||||
from govee_local_api import GoveeController
|
||||
@@ -39,7 +40,11 @@ async def _async_has_devices(hass: HomeAssistant) -> bool:
|
||||
update_enabled=False,
|
||||
)
|
||||
|
||||
await controller.start()
|
||||
try:
|
||||
await controller.start()
|
||||
except OSError as ex:
|
||||
_LOGGER.error("Start failed, errno: %d", ex.errno)
|
||||
return False
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(delay=DISCOVERY_TIMEOUT):
|
||||
@@ -49,7 +54,9 @@ async def _async_has_devices(hass: HomeAssistant) -> bool:
|
||||
_LOGGER.debug("No devices found")
|
||||
|
||||
devices_count = len(controller.devices)
|
||||
controller.cleanup()
|
||||
cleanup_complete: asyncio.Event = controller.cleanup()
|
||||
with suppress(TimeoutError):
|
||||
await asyncio.wait_for(cleanup_complete.wait(), 1)
|
||||
|
||||
return devices_count > 0
|
||||
|
||||
|
@@ -1,5 +1,6 @@
|
||||
"""Coordinator for Govee light local."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
|
||||
@@ -54,9 +55,9 @@ class GoveeLocalApiCoordinator(DataUpdateCoordinator[list[GoveeDevice]]):
|
||||
"""Set discovery callback for automatic Govee light discovery."""
|
||||
self._controller.set_device_discovered_callback(callback)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
def cleanup(self) -> asyncio.Event:
|
||||
"""Stop and cleanup the cooridinator."""
|
||||
self._controller.cleanup()
|
||||
return self._controller.cleanup()
|
||||
|
||||
async def turn_on(self, device: GoveeDevice) -> None:
|
||||
"""Turn on the light."""
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"dependencies": ["network"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/govee_light_local",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["govee-local-api==1.4.5"]
|
||||
"requirements": ["govee-local-api==1.5.0"]
|
||||
}
|
||||
|
@@ -34,7 +34,7 @@ class GuardianDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
entry: ConfigEntry,
|
||||
client: Client,
|
||||
api_name: str,
|
||||
api_coro: Callable[..., Coroutine[Any, Any, dict[str, Any]]],
|
||||
api_coro: Callable[[], Coroutine[Any, Any, dict[str, Any]]],
|
||||
api_lock: asyncio.Lock,
|
||||
valve_controller_uid: str,
|
||||
) -> None:
|
||||
|
@@ -275,7 +275,7 @@ def async_remove_addons_from_dev_reg(
|
||||
dev_reg.async_remove_device(dev.id)
|
||||
|
||||
|
||||
class HassioDataUpdateCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-coordinator-module
|
||||
class HassioDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
"""Class to retrieve Hass.io status."""
|
||||
|
||||
def __init__(
|
||||
|
@@ -59,6 +59,8 @@ CONF_MAX_WIDTH = "max_width"
|
||||
CONF_STREAM_ADDRESS = "stream_address"
|
||||
CONF_STREAM_SOURCE = "stream_source"
|
||||
CONF_SUPPORT_AUDIO = "support_audio"
|
||||
CONF_THRESHOLD_CO = "co_threshold"
|
||||
CONF_THRESHOLD_CO2 = "co2_threshold"
|
||||
CONF_VIDEO_CODEC = "video_codec"
|
||||
CONF_VIDEO_PROFILE_NAMES = "video_profile_names"
|
||||
CONF_VIDEO_MAP = "video_map"
|
||||
|
@@ -41,6 +41,8 @@ from .const import (
|
||||
CHAR_PM25_DENSITY,
|
||||
CHAR_SMOKE_DETECTED,
|
||||
CHAR_VOC_DENSITY,
|
||||
CONF_THRESHOLD_CO,
|
||||
CONF_THRESHOLD_CO2,
|
||||
PROP_CELSIUS,
|
||||
PROP_MAX_VALUE,
|
||||
PROP_MIN_VALUE,
|
||||
@@ -335,6 +337,10 @@ class CarbonMonoxideSensor(HomeAccessory):
|
||||
SERV_CARBON_MONOXIDE_SENSOR,
|
||||
[CHAR_CARBON_MONOXIDE_LEVEL, CHAR_CARBON_MONOXIDE_PEAK_LEVEL],
|
||||
)
|
||||
|
||||
self.threshold_co = self.config.get(CONF_THRESHOLD_CO, THRESHOLD_CO)
|
||||
_LOGGER.debug("%s: Set CO threshold to %d", self.entity_id, self.threshold_co)
|
||||
|
||||
self.char_level = serv_co.configure_char(CHAR_CARBON_MONOXIDE_LEVEL, value=0)
|
||||
self.char_peak = serv_co.configure_char(
|
||||
CHAR_CARBON_MONOXIDE_PEAK_LEVEL, value=0
|
||||
@@ -353,7 +359,7 @@ class CarbonMonoxideSensor(HomeAccessory):
|
||||
self.char_level.set_value(value)
|
||||
if value > self.char_peak.value:
|
||||
self.char_peak.set_value(value)
|
||||
co_detected = value > THRESHOLD_CO
|
||||
co_detected = value > self.threshold_co
|
||||
self.char_detected.set_value(co_detected)
|
||||
_LOGGER.debug("%s: Set to %d", self.entity_id, value)
|
||||
|
||||
@@ -371,6 +377,10 @@ class CarbonDioxideSensor(HomeAccessory):
|
||||
SERV_CARBON_DIOXIDE_SENSOR,
|
||||
[CHAR_CARBON_DIOXIDE_LEVEL, CHAR_CARBON_DIOXIDE_PEAK_LEVEL],
|
||||
)
|
||||
|
||||
self.threshold_co2 = self.config.get(CONF_THRESHOLD_CO2, THRESHOLD_CO2)
|
||||
_LOGGER.debug("%s: Set CO2 threshold to %d", self.entity_id, self.threshold_co2)
|
||||
|
||||
self.char_level = serv_co2.configure_char(CHAR_CARBON_DIOXIDE_LEVEL, value=0)
|
||||
self.char_peak = serv_co2.configure_char(
|
||||
CHAR_CARBON_DIOXIDE_PEAK_LEVEL, value=0
|
||||
@@ -389,7 +399,7 @@ class CarbonDioxideSensor(HomeAccessory):
|
||||
self.char_level.set_value(value)
|
||||
if value > self.char_peak.value:
|
||||
self.char_peak.set_value(value)
|
||||
co2_detected = value > THRESHOLD_CO2
|
||||
co2_detected = value > self.threshold_co2
|
||||
self.char_detected.set_value(co2_detected)
|
||||
_LOGGER.debug("%s: Set to %d", self.entity_id, value)
|
||||
|
||||
|
@@ -72,6 +72,8 @@ from .const import (
|
||||
CONF_STREAM_COUNT,
|
||||
CONF_STREAM_SOURCE,
|
||||
CONF_SUPPORT_AUDIO,
|
||||
CONF_THRESHOLD_CO,
|
||||
CONF_THRESHOLD_CO2,
|
||||
CONF_VIDEO_CODEC,
|
||||
CONF_VIDEO_MAP,
|
||||
CONF_VIDEO_PACKET_SIZE,
|
||||
@@ -223,6 +225,13 @@ SWITCH_TYPE_SCHEMA = BASIC_INFO_SCHEMA.extend(
|
||||
}
|
||||
)
|
||||
|
||||
SENSOR_SCHEMA = BASIC_INFO_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(CONF_THRESHOLD_CO): vol.Any(None, cv.positive_int),
|
||||
vol.Optional(CONF_THRESHOLD_CO2): vol.Any(None, cv.positive_int),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
HOMEKIT_CHAR_TRANSLATIONS = {
|
||||
0: " ", # nul
|
||||
@@ -297,6 +306,9 @@ def validate_entity_config(values: dict) -> dict[str, dict]:
|
||||
elif domain == "cover":
|
||||
config = COVER_SCHEMA(config)
|
||||
|
||||
elif domain == "sensor":
|
||||
config = SENSOR_SCHEMA(config)
|
||||
|
||||
else:
|
||||
config = BASIC_INFO_SCHEMA(config)
|
||||
|
||||
|
@@ -44,14 +44,14 @@ BUTTON_ENTITIES: dict[str, HomeKitButtonEntityDescription] = {
|
||||
name="Setup",
|
||||
translation_key="setup",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
write_value="#HAA@trcmd",
|
||||
write_value="#HAA@trcmd", # codespell:ignore haa
|
||||
),
|
||||
CharacteristicsTypes.VENDOR_HAA_UPDATE: HomeKitButtonEntityDescription(
|
||||
key=CharacteristicsTypes.VENDOR_HAA_UPDATE,
|
||||
name="Update",
|
||||
device_class=ButtonDeviceClass.UPDATE,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
write_value="#HAA@trcmd",
|
||||
write_value="#HAA@trcmd", # codespell:ignore haa
|
||||
),
|
||||
CharacteristicsTypes.IDENTIFY: HomeKitButtonEntityDescription(
|
||||
key=CharacteristicsTypes.IDENTIFY,
|
||||
|
@@ -110,7 +110,7 @@ class HKDevice:
|
||||
# A list of callbacks that turn HK characteristics into entities
|
||||
self.char_factories: list[AddCharacteristicCb] = []
|
||||
|
||||
# The platorms we have forwarded the config entry so far. If a new
|
||||
# The platforms we have forwarded the config entry so far. If a new
|
||||
# accessory is added to a bridge we may have to load additional
|
||||
# platforms. We don't want to load all platforms up front if its just
|
||||
# a lightbulb. And we don't want to forward a config entry twice
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from aiohttp.client_exceptions import ClientConnectionError
|
||||
import aiosomecomfort
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -68,6 +69,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
|
||||
aiosomecomfort.device.ConnectionError,
|
||||
aiosomecomfort.device.ConnectionTimeout,
|
||||
aiosomecomfort.device.SomeComfortError,
|
||||
ClientConnectionError,
|
||||
TimeoutError,
|
||||
) as ex:
|
||||
raise ConfigEntryNotReady(
|
||||
|
@@ -54,6 +54,7 @@ class HoneywellConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Confirm re-authentication with Honeywell."""
|
||||
errors: dict[str, str] = {}
|
||||
assert self.entry is not None
|
||||
|
||||
if user_input:
|
||||
try:
|
||||
await self.is_valid(
|
||||
@@ -63,14 +64,12 @@ class HoneywellConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
except aiosomecomfort.AuthError:
|
||||
errors["base"] = "invalid_auth"
|
||||
|
||||
except (
|
||||
aiosomecomfort.ConnectionError,
|
||||
aiosomecomfort.ConnectionTimeout,
|
||||
TimeoutError,
|
||||
):
|
||||
errors["base"] = "cannot_connect"
|
||||
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
self.entry,
|
||||
@@ -83,7 +82,8 @@ class HoneywellConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
REAUTH_SCHEMA, self.entry.data
|
||||
REAUTH_SCHEMA,
|
||||
self.entry.data,
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders={"name": "Honeywell"},
|
||||
@@ -91,7 +91,7 @@ class HoneywellConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
async def async_step_user(self, user_input=None) -> ConfigFlowResult:
|
||||
"""Create config entry. Show the setup form to the user."""
|
||||
errors = {}
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
try:
|
||||
await self.is_valid(**user_input)
|
||||
@@ -103,7 +103,6 @@ class HoneywellConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
TimeoutError,
|
||||
):
|
||||
errors["base"] = "cannot_connect"
|
||||
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title=DOMAIN,
|
||||
@@ -115,7 +114,9 @@ class HoneywellConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=vol.Schema(data_schema), errors=errors
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(data_schema),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def is_valid(self, **kwargs) -> bool:
|
||||
|
@@ -10,8 +10,7 @@ import os
|
||||
import socket
|
||||
import ssl
|
||||
from tempfile import NamedTemporaryFile
|
||||
from typing import Any, Final, Required, TypedDict, cast
|
||||
from urllib.parse import quote_plus, urljoin
|
||||
from typing import Any, Final, TypedDict, cast
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.abc import AbstractStreamWriter
|
||||
@@ -30,20 +29,8 @@ from yarl import URL
|
||||
|
||||
from homeassistant.components.network import async_get_source_ip
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, SERVER_PORT
|
||||
from homeassistant.core import (
|
||||
Event,
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
ServiceResponse,
|
||||
SupportsResponse,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import (
|
||||
HomeAssistantError,
|
||||
ServiceValidationError,
|
||||
Unauthorized,
|
||||
UnknownUser,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import storage
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.http import (
|
||||
@@ -66,14 +53,9 @@ from homeassistant.util import dt as dt_util, ssl as ssl_util
|
||||
from homeassistant.util.async_ import create_eager_task
|
||||
from homeassistant.util.json import json_loads
|
||||
|
||||
from .auth import async_setup_auth, async_sign_path
|
||||
from .auth import async_setup_auth
|
||||
from .ban import setup_bans
|
||||
from .const import ( # noqa: F401
|
||||
DOMAIN,
|
||||
KEY_HASS_REFRESH_TOKEN_ID,
|
||||
KEY_HASS_USER,
|
||||
StrictConnectionMode,
|
||||
)
|
||||
from .const import DOMAIN, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER # noqa: F401
|
||||
from .cors import setup_cors
|
||||
from .decorators import require_admin # noqa: F401
|
||||
from .forwarded import async_setup_forwarded
|
||||
@@ -96,7 +78,6 @@ CONF_TRUSTED_PROXIES: Final = "trusted_proxies"
|
||||
CONF_LOGIN_ATTEMPTS_THRESHOLD: Final = "login_attempts_threshold"
|
||||
CONF_IP_BAN_ENABLED: Final = "ip_ban_enabled"
|
||||
CONF_SSL_PROFILE: Final = "ssl_profile"
|
||||
CONF_STRICT_CONNECTION: Final = "strict_connection"
|
||||
|
||||
SSL_MODERN: Final = "modern"
|
||||
SSL_INTERMEDIATE: Final = "intermediate"
|
||||
@@ -146,9 +127,6 @@ HTTP_SCHEMA: Final = vol.All(
|
||||
[SSL_INTERMEDIATE, SSL_MODERN]
|
||||
),
|
||||
vol.Optional(CONF_USE_X_FRAME_OPTIONS, default=True): cv.boolean,
|
||||
vol.Optional(
|
||||
CONF_STRICT_CONNECTION, default=StrictConnectionMode.DISABLED
|
||||
): vol.Coerce(StrictConnectionMode),
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -172,7 +150,6 @@ class ConfData(TypedDict, total=False):
|
||||
login_attempts_threshold: int
|
||||
ip_ban_enabled: bool
|
||||
ssl_profile: str
|
||||
strict_connection: Required[StrictConnectionMode]
|
||||
|
||||
|
||||
@bind_hass
|
||||
@@ -241,7 +218,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
login_threshold=login_threshold,
|
||||
is_ban_enabled=is_ban_enabled,
|
||||
use_x_frame_options=use_x_frame_options,
|
||||
strict_connection_non_cloud=conf[CONF_STRICT_CONNECTION],
|
||||
)
|
||||
|
||||
async def stop_server(event: Event) -> None:
|
||||
@@ -271,7 +247,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
local_ip, host, server_port, ssl_certificate is not None
|
||||
)
|
||||
|
||||
_setup_services(hass, conf)
|
||||
return True
|
||||
|
||||
|
||||
@@ -356,7 +331,6 @@ class HomeAssistantHTTP:
|
||||
login_threshold: int,
|
||||
is_ban_enabled: bool,
|
||||
use_x_frame_options: bool,
|
||||
strict_connection_non_cloud: StrictConnectionMode,
|
||||
) -> None:
|
||||
"""Initialize the server."""
|
||||
self.app[KEY_HASS] = self.hass
|
||||
@@ -373,7 +347,7 @@ class HomeAssistantHTTP:
|
||||
if is_ban_enabled:
|
||||
setup_bans(self.hass, self.app, login_threshold)
|
||||
|
||||
await async_setup_auth(self.hass, self.app, strict_connection_non_cloud)
|
||||
await async_setup_auth(self.hass, self.app)
|
||||
|
||||
setup_headers(self.app, use_x_frame_options)
|
||||
setup_cors(self.app, cors_origins)
|
||||
@@ -602,61 +576,3 @@ async def start_http_server_and_save_config(
|
||||
]
|
||||
|
||||
store.async_delay_save(lambda: conf, SAVE_DELAY)
|
||||
|
||||
|
||||
@callback
|
||||
def _setup_services(hass: HomeAssistant, conf: ConfData) -> None:
|
||||
"""Set up services for HTTP component."""
|
||||
|
||||
async def create_temporary_strict_connection_url(
|
||||
call: ServiceCall,
|
||||
) -> ServiceResponse:
|
||||
"""Create a strict connection url and return it."""
|
||||
# Copied form homeassistant/helpers/service.py#_async_admin_handler
|
||||
# as the helper supports no responses yet
|
||||
if call.context.user_id:
|
||||
user = await hass.auth.async_get_user(call.context.user_id)
|
||||
if user is None:
|
||||
raise UnknownUser(context=call.context)
|
||||
if not user.is_admin:
|
||||
raise Unauthorized(context=call.context)
|
||||
|
||||
if conf[CONF_STRICT_CONNECTION] is StrictConnectionMode.DISABLED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="strict_connection_not_enabled_non_cloud",
|
||||
)
|
||||
|
||||
try:
|
||||
url = get_url(
|
||||
hass, prefer_external=True, allow_internal=False, allow_cloud=False
|
||||
)
|
||||
except NoURLAvailableError as ex:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_external_url_available",
|
||||
) from ex
|
||||
|
||||
# to avoid circular import
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from homeassistant.components.auth import STRICT_CONNECTION_URL
|
||||
|
||||
path = async_sign_path(
|
||||
hass,
|
||||
STRICT_CONNECTION_URL,
|
||||
datetime.timedelta(hours=1),
|
||||
use_content_user=True,
|
||||
)
|
||||
url = urljoin(url, path)
|
||||
|
||||
return {
|
||||
"url": f"https://login.home-assistant.io?u={quote_plus(url)}",
|
||||
"direct_url": url,
|
||||
}
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"create_temporary_strict_connection_url",
|
||||
create_temporary_strict_connection_url,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
@@ -4,18 +4,14 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import timedelta
|
||||
from http import HTTPStatus
|
||||
from ipaddress import ip_address
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any, Final
|
||||
|
||||
from aiohttp import hdrs
|
||||
from aiohttp.web import Application, Request, Response, StreamResponse, middleware
|
||||
from aiohttp.web_exceptions import HTTPBadRequest
|
||||
from aiohttp_session import session_middleware
|
||||
from aiohttp.web import Application, Request, StreamResponse, middleware
|
||||
import jwt
|
||||
from jwt import api_jws
|
||||
from yarl import URL
|
||||
@@ -25,21 +21,13 @@ from homeassistant.auth.const import GROUP_ID_READ_ONLY
|
||||
from homeassistant.auth.models import User
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import singleton
|
||||
from homeassistant.helpers.http import current_request
|
||||
from homeassistant.helpers.json import json_bytes
|
||||
from homeassistant.helpers.network import is_cloud_connection
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.util.network import is_local
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
KEY_AUTHENTICATED,
|
||||
KEY_HASS_REFRESH_TOKEN_ID,
|
||||
KEY_HASS_USER,
|
||||
StrictConnectionMode,
|
||||
)
|
||||
from .session import HomeAssistantCookieStorage
|
||||
from .const import KEY_AUTHENTICATED, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -51,11 +39,6 @@ SAFE_QUERY_PARAMS: Final = ["height", "width"]
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_KEY = "http.auth"
|
||||
CONTENT_USER_NAME = "Home Assistant Content"
|
||||
STRICT_CONNECTION_EXCLUDED_PATH = "/api/webhook/"
|
||||
STRICT_CONNECTION_GUARD_PAGE_NAME = "strict_connection_guard_page.html"
|
||||
STRICT_CONNECTION_GUARD_PAGE = os.path.join(
|
||||
os.path.dirname(__file__), STRICT_CONNECTION_GUARD_PAGE_NAME
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
@@ -137,7 +120,6 @@ def async_user_not_allowed_do_auth(
|
||||
async def async_setup_auth(
|
||||
hass: HomeAssistant,
|
||||
app: Application,
|
||||
strict_connection_mode_non_cloud: StrictConnectionMode,
|
||||
) -> None:
|
||||
"""Create auth middleware for the app."""
|
||||
store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY)
|
||||
@@ -160,10 +142,6 @@ async def async_setup_auth(
|
||||
|
||||
hass.data[STORAGE_KEY] = refresh_token.id
|
||||
|
||||
if strict_connection_mode_non_cloud is StrictConnectionMode.GUARD_PAGE:
|
||||
# Load the guard page content on setup
|
||||
await _read_strict_connection_guard_page(hass)
|
||||
|
||||
@callback
|
||||
def async_validate_auth_header(request: Request) -> bool:
|
||||
"""Test authorization header against access token.
|
||||
@@ -252,37 +230,6 @@ async def async_setup_auth(
|
||||
authenticated = True
|
||||
auth_type = "signed request"
|
||||
|
||||
if not authenticated and not request.path.startswith(
|
||||
STRICT_CONNECTION_EXCLUDED_PATH
|
||||
):
|
||||
strict_connection_mode = strict_connection_mode_non_cloud
|
||||
strict_connection_func = (
|
||||
_async_perform_strict_connection_action_on_non_local
|
||||
)
|
||||
if is_cloud_connection(hass):
|
||||
from homeassistant.components.cloud.util import ( # pylint: disable=import-outside-toplevel
|
||||
get_strict_connection_mode,
|
||||
)
|
||||
|
||||
strict_connection_mode = get_strict_connection_mode(hass)
|
||||
strict_connection_func = _async_perform_strict_connection_action
|
||||
|
||||
if (
|
||||
strict_connection_mode is not StrictConnectionMode.DISABLED
|
||||
and not await hass.auth.session.async_validate_request_for_strict_connection_session(
|
||||
request
|
||||
)
|
||||
and (
|
||||
resp := await strict_connection_func(
|
||||
hass,
|
||||
request,
|
||||
strict_connection_mode is StrictConnectionMode.GUARD_PAGE,
|
||||
)
|
||||
)
|
||||
is not None
|
||||
):
|
||||
return resp
|
||||
|
||||
if authenticated and _LOGGER.isEnabledFor(logging.DEBUG):
|
||||
_LOGGER.debug(
|
||||
"Authenticated %s for %s using %s",
|
||||
@@ -294,69 +241,4 @@ async def async_setup_auth(
|
||||
request[KEY_AUTHENTICATED] = authenticated
|
||||
return await handler(request)
|
||||
|
||||
app.middlewares.append(session_middleware(HomeAssistantCookieStorage(hass)))
|
||||
app.middlewares.append(auth_middleware)
|
||||
|
||||
|
||||
async def _async_perform_strict_connection_action_on_non_local(
|
||||
hass: HomeAssistant,
|
||||
request: Request,
|
||||
guard_page: bool,
|
||||
) -> StreamResponse | None:
|
||||
"""Perform strict connection mode action if the request is not local.
|
||||
|
||||
The function does the following:
|
||||
- Try to get the IP address of the request. If it fails, assume it's not local
|
||||
- If the request is local, return None (allow the request to continue)
|
||||
- If guard_page is True, return a response with the content
|
||||
- Otherwise close the connection and raise an exception
|
||||
"""
|
||||
try:
|
||||
ip_address_ = ip_address(request.remote) # type: ignore[arg-type]
|
||||
except ValueError:
|
||||
_LOGGER.debug("Invalid IP address: %s", request.remote)
|
||||
ip_address_ = None
|
||||
|
||||
if ip_address_ and is_local(ip_address_):
|
||||
return None
|
||||
|
||||
return await _async_perform_strict_connection_action(hass, request, guard_page)
|
||||
|
||||
|
||||
async def _async_perform_strict_connection_action(
|
||||
hass: HomeAssistant,
|
||||
request: Request,
|
||||
guard_page: bool,
|
||||
) -> StreamResponse | None:
|
||||
"""Perform strict connection mode action.
|
||||
|
||||
The function does the following:
|
||||
- If guard_page is True, return a response with the content
|
||||
- Otherwise close the connection and raise an exception
|
||||
"""
|
||||
|
||||
_LOGGER.debug("Perform strict connection action for %s", request.remote)
|
||||
if guard_page:
|
||||
return Response(
|
||||
text=await _read_strict_connection_guard_page(hass),
|
||||
content_type="text/html",
|
||||
status=HTTPStatus.IM_A_TEAPOT,
|
||||
)
|
||||
|
||||
if transport := request.transport:
|
||||
# it should never happen that we don't have a transport
|
||||
transport.close()
|
||||
|
||||
# We need to raise an exception to stop processing the request
|
||||
raise HTTPBadRequest
|
||||
|
||||
|
||||
@singleton.singleton(f"{DOMAIN}_{STRICT_CONNECTION_GUARD_PAGE_NAME}")
|
||||
async def _read_strict_connection_guard_page(hass: HomeAssistant) -> str:
|
||||
"""Read the strict connection guard page from disk via executor."""
|
||||
|
||||
def read_guard_page() -> str:
|
||||
with open(STRICT_CONNECTION_GUARD_PAGE, encoding="utf-8") as file:
|
||||
return file.read()
|
||||
|
||||
return await hass.async_add_executor_job(read_guard_page)
|
||||
|
@@ -1,6 +1,5 @@
|
||||
"""HTTP specific constants."""
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.helpers.http import KEY_AUTHENTICATED, KEY_HASS # noqa: F401
|
||||
@@ -9,11 +8,3 @@ DOMAIN: Final = "http"
|
||||
|
||||
KEY_HASS_USER: Final = "hass_user"
|
||||
KEY_HASS_REFRESH_TOKEN_ID: Final = "hass_refresh_token_id"
|
||||
|
||||
|
||||
class StrictConnectionMode(StrEnum):
|
||||
"""Enum for strict connection mode."""
|
||||
|
||||
DISABLED = "disabled"
|
||||
GUARD_PAGE = "guard_page"
|
||||
DROP_CONNECTION = "drop_connection"
|
||||
|
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"services": {
|
||||
"create_temporary_strict_connection_url": "mdi:login-variant"
|
||||
}
|
||||
}
|
@@ -1 +0,0 @@
|
||||
create_temporary_strict_connection_url: ~
|
@@ -1,160 +0,0 @@
|
||||
"""Session http module."""
|
||||
|
||||
from functools import lru_cache
|
||||
import logging
|
||||
|
||||
from aiohttp.web import Request, StreamResponse
|
||||
from aiohttp_session import Session, SessionData
|
||||
from aiohttp_session.cookie_storage import EncryptedCookieStorage
|
||||
from cryptography.fernet import InvalidToken
|
||||
|
||||
from homeassistant.auth.const import REFRESH_TOKEN_EXPIRATION
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
from homeassistant.helpers.network import is_cloud_connection
|
||||
from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads
|
||||
|
||||
from .ban import process_wrong_login
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
COOKIE_NAME = "SC"
|
||||
PREFIXED_COOKIE_NAME = f"__Host-{COOKIE_NAME}"
|
||||
SESSION_CACHE_SIZE = 16
|
||||
|
||||
|
||||
def _get_cookie_name(is_secure: bool) -> str:
|
||||
"""Return the cookie name."""
|
||||
return PREFIXED_COOKIE_NAME if is_secure else COOKIE_NAME
|
||||
|
||||
|
||||
class HomeAssistantCookieStorage(EncryptedCookieStorage):
|
||||
"""Home Assistant cookie storage.
|
||||
|
||||
Own class is required:
|
||||
- to set the secure flag based on the connection type
|
||||
- to use a LRU cache for session decryption
|
||||
"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the cookie storage."""
|
||||
super().__init__(
|
||||
hass.auth.session.key,
|
||||
cookie_name=PREFIXED_COOKIE_NAME,
|
||||
max_age=int(REFRESH_TOKEN_EXPIRATION),
|
||||
httponly=True,
|
||||
samesite="Lax",
|
||||
secure=True,
|
||||
encoder=json_dumps,
|
||||
decoder=json_loads,
|
||||
)
|
||||
self._hass = hass
|
||||
|
||||
def _secure_connection(self, request: Request) -> bool:
|
||||
"""Return if the connection is secure (https)."""
|
||||
return is_cloud_connection(self._hass) or request.secure
|
||||
|
||||
def load_cookie(self, request: Request) -> str | None:
|
||||
"""Load cookie."""
|
||||
is_secure = self._secure_connection(request)
|
||||
cookie_name = _get_cookie_name(is_secure)
|
||||
return request.cookies.get(cookie_name)
|
||||
|
||||
@lru_cache(maxsize=SESSION_CACHE_SIZE)
|
||||
def _decrypt_cookie(self, cookie: str) -> Session | None:
|
||||
"""Decrypt and validate cookie."""
|
||||
try:
|
||||
data = SessionData( # type: ignore[misc]
|
||||
self._decoder(
|
||||
self._fernet.decrypt(
|
||||
cookie.encode("utf-8"), ttl=self.max_age
|
||||
).decode("utf-8")
|
||||
)
|
||||
)
|
||||
except (InvalidToken, TypeError, ValueError, *JSON_DECODE_EXCEPTIONS):
|
||||
_LOGGER.warning("Cannot decrypt/parse cookie value")
|
||||
return None
|
||||
|
||||
session = Session(None, data=data, new=data is None, max_age=self.max_age)
|
||||
|
||||
# Validate session if not empty
|
||||
if (
|
||||
not session.empty
|
||||
and not self._hass.auth.session.async_validate_strict_connection_session(
|
||||
session
|
||||
)
|
||||
):
|
||||
# Invalidate session as it is not valid
|
||||
session.invalidate()
|
||||
|
||||
return session
|
||||
|
||||
async def new_session(self) -> Session:
|
||||
"""Create a new session and mark it as changed."""
|
||||
session = Session(None, data=None, new=True, max_age=self.max_age)
|
||||
session.changed()
|
||||
return session
|
||||
|
||||
async def load_session(self, request: Request) -> Session:
|
||||
"""Load session."""
|
||||
# Split parent function to use lru_cache
|
||||
if (cookie := self.load_cookie(request)) is None:
|
||||
return await self.new_session()
|
||||
|
||||
if (session := self._decrypt_cookie(cookie)) is None:
|
||||
# Decrypting/parsing failed, log wrong login and create a new session
|
||||
await process_wrong_login(request)
|
||||
session = await self.new_session()
|
||||
|
||||
return session
|
||||
|
||||
async def save_session(
|
||||
self, request: Request, response: StreamResponse, session: Session
|
||||
) -> None:
|
||||
"""Save session."""
|
||||
|
||||
is_secure = self._secure_connection(request)
|
||||
cookie_name = _get_cookie_name(is_secure)
|
||||
|
||||
if session.empty:
|
||||
response.del_cookie(cookie_name)
|
||||
else:
|
||||
params = self.cookie_params.copy()
|
||||
params["secure"] = is_secure
|
||||
params["max_age"] = session.max_age
|
||||
|
||||
cookie_data = self._encoder(self._get_session_data(session)).encode("utf-8")
|
||||
response.set_cookie(
|
||||
cookie_name,
|
||||
self._fernet.encrypt(cookie_data).decode("utf-8"),
|
||||
**params,
|
||||
)
|
||||
# Add Cache-Control header to not cache the cookie as it
|
||||
# is used for session management
|
||||
self._add_cache_control_header(response)
|
||||
|
||||
@staticmethod
|
||||
def _add_cache_control_header(response: StreamResponse) -> None:
|
||||
"""Add/set cache control header to no-cache="Set-Cookie"."""
|
||||
# Structure of the Cache-Control header defined in
|
||||
# https://datatracker.ietf.org/doc/html/rfc2068#section-14.9
|
||||
if header := response.headers.get("Cache-Control"):
|
||||
directives = []
|
||||
for directive in header.split(","):
|
||||
directive = directive.strip()
|
||||
directive_lowered = directive.lower()
|
||||
if directive_lowered.startswith("no-cache"):
|
||||
if "set-cookie" in directive_lowered or directive.find("=") == -1:
|
||||
# Set-Cookie is already in the no-cache directive or
|
||||
# the whole request should not be cached -> Nothing to do
|
||||
return
|
||||
|
||||
# Add Set-Cookie to the no-cache
|
||||
# [:-1] to remove the " at the end of the directive
|
||||
directive = f"{directive[:-1]}, Set-Cookie"
|
||||
|
||||
directives.append(directive)
|
||||
header = ", ".join(directives)
|
||||
else:
|
||||
header = 'no-cache="Set-Cookie"'
|
||||
response.headers["Cache-Control"] = header
|
File diff suppressed because one or more lines are too long
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"exceptions": {
|
||||
"strict_connection_not_enabled_non_cloud": {
|
||||
"message": "Strict connection is not enabled for non-cloud requests"
|
||||
},
|
||||
"no_external_url_available": {
|
||||
"message": "No external URL available"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"create_temporary_strict_connection_url": {
|
||||
"name": "Create a temporary strict connection URL",
|
||||
"description": "Create a temporary strict connection URL, which can be used to login on another device."
|
||||
}
|
||||
}
|
||||
}
|
@@ -27,7 +27,7 @@ class HomeAssistantTCPSite(web.BaseSite):
|
||||
def __init__(
|
||||
self,
|
||||
runner: web.BaseRunner,
|
||||
host: None | str | list[str],
|
||||
host: str | list[str] | None,
|
||||
port: int,
|
||||
*,
|
||||
ssl_context: SSLContext | None = None,
|
||||
|
@@ -303,7 +303,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = {
|
||||
key="rsrp",
|
||||
translation_key="rsrp",
|
||||
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||
# http://www.lte-anbieter.info/technik/rsrp.php
|
||||
# http://www.lte-anbieter.info/technik/rsrp.php # codespell:ignore technik
|
||||
icon_fn=lambda x: signal_icon((-110, -95, -80), x),
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -313,7 +313,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = {
|
||||
key="rsrq",
|
||||
translation_key="rsrq",
|
||||
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||
# http://www.lte-anbieter.info/technik/rsrq.php
|
||||
# http://www.lte-anbieter.info/technik/rsrq.php # codespell:ignore technik
|
||||
icon_fn=lambda x: signal_icon((-11, -8, -5), x),
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -333,7 +333,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = {
|
||||
key="sinr",
|
||||
translation_key="sinr",
|
||||
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||
# http://www.lte-anbieter.info/technik/sinr.php
|
||||
# http://www.lte-anbieter.info/technik/sinr.php # codespell:ignore technik
|
||||
icon_fn=lambda x: signal_icon((0, 5, 10), x),
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
|
@@ -1,6 +1,7 @@
|
||||
"""The constants for the Husqvarna Automower integration."""
|
||||
|
||||
DOMAIN = "husqvarna_automower"
|
||||
EXECUTION_TIME_DELAY = 5
|
||||
NAME = "Husqvarna Automower"
|
||||
OAUTH2_AUTHORIZE = "https://api.authentication.husqvarnagroup.dev/v1/oauth2/authorize"
|
||||
OAUTH2_TOKEN = "https://api.authentication.husqvarnagroup.dev/v1/oauth2/token"
|
||||
|
@@ -12,13 +12,13 @@ from aioautomower.session import AutomowerSession
|
||||
|
||||
from homeassistant.components.number import NumberEntity, NumberEntityDescription
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import DOMAIN, EXECUTION_TIME_DELAY
|
||||
from .coordinator import AutomowerDataUpdateCoordinator
|
||||
from .entity import AutomowerControlEntity
|
||||
|
||||
@@ -52,10 +52,6 @@ async def async_set_work_area_cutting_height(
|
||||
await coordinator.api.commands.set_cutting_height_workarea(
|
||||
mower_id, int(cheight), work_area_id
|
||||
)
|
||||
# As there are no updates from the websocket regarding work area changes,
|
||||
# we need to wait 5s and then poll the API.
|
||||
await asyncio.sleep(5)
|
||||
await coordinator.async_request_refresh()
|
||||
|
||||
|
||||
async def async_set_cutting_height(
|
||||
@@ -189,6 +185,7 @@ class AutomowerWorkAreaNumberEntity(AutomowerControlEntity, NumberEntity):
|
||||
) -> None:
|
||||
"""Set up AutomowerNumberEntity."""
|
||||
super().__init__(mower_id, coordinator)
|
||||
self.coordinator = coordinator
|
||||
self.entity_description = description
|
||||
self.work_area_id = work_area_id
|
||||
self._attr_unique_id = f"{mower_id}_{work_area_id}_{description.key}"
|
||||
@@ -221,6 +218,11 @@ class AutomowerWorkAreaNumberEntity(AutomowerControlEntity, NumberEntity):
|
||||
raise HomeAssistantError(
|
||||
f"Command couldn't be sent to the command queue: {exception}"
|
||||
) from exception
|
||||
else:
|
||||
# As there are no updates from the websocket regarding work area changes,
|
||||
# we need to wait 5s and then poll the API.
|
||||
await asyncio.sleep(EXECUTION_TIME_DELAY)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
|
||||
@callback
|
||||
@@ -238,10 +240,13 @@ def async_remove_entities(
|
||||
for work_area_id in _work_areas:
|
||||
uid = f"{mower_id}_{work_area_id}_cutting_height_work_area"
|
||||
active_work_areas.add(uid)
|
||||
for entity_entry in er.async_entries_for_config_entry(
|
||||
entity_reg, config_entry.entry_id
|
||||
for entity_entry in er.async_entries_for_config_entry(
|
||||
entity_reg, config_entry.entry_id
|
||||
):
|
||||
if (
|
||||
entity_entry.domain == Platform.NUMBER
|
||||
and (split := entity_entry.unique_id.split("_"))[0] == mower_id
|
||||
and split[-1] == "area"
|
||||
and entity_entry.unique_id not in active_work_areas
|
||||
):
|
||||
if entity_entry.unique_id.split("_")[0] == mower_id:
|
||||
if entity_entry.unique_id.endswith("cutting_height_work_area"):
|
||||
if entity_entry.unique_id not in active_work_areas:
|
||||
entity_reg.async_remove(entity_entry.entity_id)
|
||||
entity_reg.async_remove(entity_entry.entity_id)
|
||||
|
@@ -1,4 +1,4 @@
|
||||
"""Creates a the sensor entities for the mower."""
|
||||
"""Creates the sensor entities for the mower."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
@@ -15,12 +15,13 @@ from aioautomower.model import (
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import DOMAIN, EXECUTION_TIME_DELAY
|
||||
from .coordinator import AutomowerDataUpdateCoordinator
|
||||
from .entity import AutomowerControlEntity
|
||||
|
||||
@@ -40,7 +41,6 @@ ERROR_STATES = [
|
||||
MowerStates.STOPPED,
|
||||
MowerStates.OFF,
|
||||
]
|
||||
EXECUTION_TIME = 5
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -172,7 +172,7 @@ class AutomowerStayOutZoneSwitchEntity(AutomowerControlEntity, SwitchEntity):
|
||||
else:
|
||||
# As there are no updates from the websocket regarding stay out zone changes,
|
||||
# we need to wait until the command is executed and then poll the API.
|
||||
await asyncio.sleep(EXECUTION_TIME)
|
||||
await asyncio.sleep(EXECUTION_TIME_DELAY)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
@@ -188,7 +188,7 @@ class AutomowerStayOutZoneSwitchEntity(AutomowerControlEntity, SwitchEntity):
|
||||
else:
|
||||
# As there are no updates from the websocket regarding stay out zone changes,
|
||||
# we need to wait until the command is executed and then poll the API.
|
||||
await asyncio.sleep(EXECUTION_TIME)
|
||||
await asyncio.sleep(EXECUTION_TIME_DELAY)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
|
||||
@@ -211,7 +211,8 @@ def async_remove_entities(
|
||||
entity_reg, config_entry.entry_id
|
||||
):
|
||||
if (
|
||||
(split := entity_entry.unique_id.split("_"))[0] == mower_id
|
||||
entity_entry.domain == Platform.SWITCH
|
||||
and (split := entity_entry.unique_id.split("_"))[0] == mower_id
|
||||
and split[-1] == "zones"
|
||||
and entity_entry.unique_id not in active_zones
|
||||
):
|
||||
|
@@ -45,6 +45,8 @@ from .timers import (
|
||||
IncreaseTimerIntentHandler,
|
||||
PauseTimerIntentHandler,
|
||||
StartTimerIntentHandler,
|
||||
TimerEventType,
|
||||
TimerInfo,
|
||||
TimerManager,
|
||||
TimerStatusIntentHandler,
|
||||
UnpauseTimerIntentHandler,
|
||||
@@ -57,6 +59,8 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
__all__ = [
|
||||
"async_register_timer_handler",
|
||||
"TimerInfo",
|
||||
"TimerEventType",
|
||||
"DOMAIN",
|
||||
]
|
||||
|
||||
|
@@ -29,6 +29,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
TIMER_NOT_FOUND_RESPONSE = "timer_not_found"
|
||||
MULTIPLE_TIMERS_MATCHED_RESPONSE = "multiple_timers_matched"
|
||||
NO_TIMER_SUPPORT_RESPONSE = "no_timer_support"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -44,7 +45,7 @@ class TimerInfo:
|
||||
seconds: int
|
||||
"""Total number of seconds the timer should run for."""
|
||||
|
||||
device_id: str | None
|
||||
device_id: str
|
||||
"""Id of the device where the timer was set."""
|
||||
|
||||
start_hours: int | None
|
||||
@@ -159,6 +160,17 @@ class MultipleTimersMatchedError(intent.IntentHandleError):
|
||||
super().__init__("Multiple timers matched", MULTIPLE_TIMERS_MATCHED_RESPONSE)
|
||||
|
||||
|
||||
class TimersNotSupportedError(intent.IntentHandleError):
|
||||
"""Error when a timer intent is used from a device that isn't registered to handle timer events."""
|
||||
|
||||
def __init__(self, device_id: str | None = None) -> None:
|
||||
"""Initialize error."""
|
||||
super().__init__(
|
||||
f"Device does not support timers: device_id={device_id}",
|
||||
NO_TIMER_SUPPORT_RESPONSE,
|
||||
)
|
||||
|
||||
|
||||
class TimerManager:
|
||||
"""Manager for intent timers."""
|
||||
|
||||
@@ -170,26 +182,36 @@ class TimerManager:
|
||||
self.timers: dict[str, TimerInfo] = {}
|
||||
self.timer_tasks: dict[str, asyncio.Task] = {}
|
||||
|
||||
self.handlers: list[TimerHandler] = []
|
||||
# device_id -> handler
|
||||
self.handlers: dict[str, TimerHandler] = {}
|
||||
|
||||
def register_handler(self, handler: TimerHandler) -> Callable[[], None]:
|
||||
def register_handler(
|
||||
self, device_id: str, handler: TimerHandler
|
||||
) -> Callable[[], None]:
|
||||
"""Register a timer handler.
|
||||
|
||||
Returns a callable to unregister.
|
||||
"""
|
||||
self.handlers.append(handler)
|
||||
return lambda: self.handlers.remove(handler)
|
||||
self.handlers[device_id] = handler
|
||||
|
||||
def unregister() -> None:
|
||||
self.handlers.pop(device_id)
|
||||
|
||||
return unregister
|
||||
|
||||
def start_timer(
|
||||
self,
|
||||
device_id: str,
|
||||
hours: int | None,
|
||||
minutes: int | None,
|
||||
seconds: int | None,
|
||||
language: str,
|
||||
device_id: str | None,
|
||||
name: str | None = None,
|
||||
) -> str:
|
||||
"""Start a timer."""
|
||||
if not self.is_timer_device(device_id):
|
||||
raise TimersNotSupportedError(device_id)
|
||||
|
||||
total_seconds = 0
|
||||
if hours is not None:
|
||||
total_seconds += 60 * 60 * hours
|
||||
@@ -232,9 +254,7 @@ class TimerManager:
|
||||
name=f"Timer {timer_id}",
|
||||
)
|
||||
|
||||
for handler in self.handlers:
|
||||
handler(TimerEventType.STARTED, timer)
|
||||
|
||||
self.handlers[timer.device_id](TimerEventType.STARTED, timer)
|
||||
_LOGGER.debug(
|
||||
"Timer started: id=%s, name=%s, hours=%s, minutes=%s, seconds=%s, device_id=%s",
|
||||
timer_id,
|
||||
@@ -272,9 +292,7 @@ class TimerManager:
|
||||
|
||||
timer.cancel()
|
||||
|
||||
for handler in self.handlers:
|
||||
handler(TimerEventType.CANCELLED, timer)
|
||||
|
||||
self.handlers[timer.device_id](TimerEventType.CANCELLED, timer)
|
||||
_LOGGER.debug(
|
||||
"Timer cancelled: id=%s, name=%s, seconds_left=%s, device_id=%s",
|
||||
timer_id,
|
||||
@@ -302,8 +320,7 @@ class TimerManager:
|
||||
name=f"Timer {timer_id}",
|
||||
)
|
||||
|
||||
for handler in self.handlers:
|
||||
handler(TimerEventType.UPDATED, timer)
|
||||
self.handlers[timer.device_id](TimerEventType.UPDATED, timer)
|
||||
|
||||
if seconds > 0:
|
||||
log_verb = "increased"
|
||||
@@ -340,9 +357,7 @@ class TimerManager:
|
||||
task = self.timer_tasks.pop(timer_id)
|
||||
task.cancel()
|
||||
|
||||
for handler in self.handlers:
|
||||
handler(TimerEventType.UPDATED, timer)
|
||||
|
||||
self.handlers[timer.device_id](TimerEventType.UPDATED, timer)
|
||||
_LOGGER.debug(
|
||||
"Timer paused: id=%s, name=%s, seconds_left=%s, device_id=%s",
|
||||
timer_id,
|
||||
@@ -367,9 +382,7 @@ class TimerManager:
|
||||
name=f"Timer {timer.id}",
|
||||
)
|
||||
|
||||
for handler in self.handlers:
|
||||
handler(TimerEventType.UPDATED, timer)
|
||||
|
||||
self.handlers[timer.device_id](TimerEventType.UPDATED, timer)
|
||||
_LOGGER.debug(
|
||||
"Timer unpaused: id=%s, name=%s, seconds_left=%s, device_id=%s",
|
||||
timer_id,
|
||||
@@ -383,9 +396,8 @@ class TimerManager:
|
||||
timer = self.timers.pop(timer_id)
|
||||
|
||||
timer.finish()
|
||||
for handler in self.handlers:
|
||||
handler(TimerEventType.FINISHED, timer)
|
||||
|
||||
self.handlers[timer.device_id](TimerEventType.FINISHED, timer)
|
||||
_LOGGER.debug(
|
||||
"Timer finished: id=%s, name=%s, device_id=%s",
|
||||
timer_id,
|
||||
@@ -393,24 +405,28 @@ class TimerManager:
|
||||
timer.device_id,
|
||||
)
|
||||
|
||||
def is_timer_device(self, device_id: str) -> bool:
|
||||
"""Return True if device has been registered to handle timer events."""
|
||||
return device_id in self.handlers
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_timer_handler(
|
||||
hass: HomeAssistant, handler: TimerHandler
|
||||
hass: HomeAssistant, device_id: str, handler: TimerHandler
|
||||
) -> Callable[[], None]:
|
||||
"""Register a handler for timer events.
|
||||
|
||||
Returns a callable to unregister.
|
||||
"""
|
||||
timer_manager: TimerManager = hass.data[TIMER_DATA]
|
||||
return timer_manager.register_handler(handler)
|
||||
return timer_manager.register_handler(device_id, handler)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _find_timer(
|
||||
hass: HomeAssistant, slots: dict[str, Any], device_id: str | None
|
||||
hass: HomeAssistant, device_id: str, slots: dict[str, Any]
|
||||
) -> TimerInfo:
|
||||
"""Match a single timer with constraints or raise an error."""
|
||||
timer_manager: TimerManager = hass.data[TIMER_DATA]
|
||||
@@ -479,7 +495,7 @@ def _find_timer(
|
||||
return matching_timers[0]
|
||||
|
||||
# Use device id
|
||||
if matching_timers and device_id:
|
||||
if matching_timers:
|
||||
matching_device_timers = [
|
||||
t for t in matching_timers if (t.device_id == device_id)
|
||||
]
|
||||
@@ -528,7 +544,7 @@ def _find_timer(
|
||||
|
||||
|
||||
def _find_timers(
|
||||
hass: HomeAssistant, slots: dict[str, Any], device_id: str | None
|
||||
hass: HomeAssistant, device_id: str, slots: dict[str, Any]
|
||||
) -> list[TimerInfo]:
|
||||
"""Match multiple timers with constraints or raise an error."""
|
||||
timer_manager: TimerManager = hass.data[TIMER_DATA]
|
||||
@@ -587,10 +603,6 @@ def _find_timers(
|
||||
# No matches
|
||||
return matching_timers
|
||||
|
||||
if not device_id:
|
||||
# Can't re-order based on area/floor
|
||||
return matching_timers
|
||||
|
||||
# Use device id to order remaining timers
|
||||
device_registry = dr.async_get(hass)
|
||||
device = device_registry.async_get(device_id)
|
||||
@@ -702,6 +714,12 @@ class StartTimerIntentHandler(intent.IntentHandler):
|
||||
timer_manager: TimerManager = hass.data[TIMER_DATA]
|
||||
slots = self.async_validate_slots(intent_obj.slots)
|
||||
|
||||
if not (
|
||||
intent_obj.device_id and timer_manager.is_timer_device(intent_obj.device_id)
|
||||
):
|
||||
# Fail early
|
||||
raise TimersNotSupportedError(intent_obj.device_id)
|
||||
|
||||
name: str | None = None
|
||||
if "name" in slots:
|
||||
name = slots["name"]["value"]
|
||||
@@ -719,11 +737,11 @@ class StartTimerIntentHandler(intent.IntentHandler):
|
||||
seconds = int(slots["seconds"]["value"])
|
||||
|
||||
timer_manager.start_timer(
|
||||
intent_obj.device_id,
|
||||
hours,
|
||||
minutes,
|
||||
seconds,
|
||||
language=intent_obj.language,
|
||||
device_id=intent_obj.device_id,
|
||||
name=name,
|
||||
)
|
||||
|
||||
@@ -747,9 +765,14 @@ class CancelTimerIntentHandler(intent.IntentHandler):
|
||||
timer_manager: TimerManager = hass.data[TIMER_DATA]
|
||||
slots = self.async_validate_slots(intent_obj.slots)
|
||||
|
||||
timer = _find_timer(hass, slots, intent_obj.device_id)
|
||||
timer_manager.cancel_timer(timer.id)
|
||||
if not (
|
||||
intent_obj.device_id and timer_manager.is_timer_device(intent_obj.device_id)
|
||||
):
|
||||
# Fail early
|
||||
raise TimersNotSupportedError(intent_obj.device_id)
|
||||
|
||||
timer = _find_timer(hass, intent_obj.device_id, slots)
|
||||
timer_manager.cancel_timer(timer.id)
|
||||
return intent_obj.create_response()
|
||||
|
||||
|
||||
@@ -771,10 +794,15 @@ class IncreaseTimerIntentHandler(intent.IntentHandler):
|
||||
timer_manager: TimerManager = hass.data[TIMER_DATA]
|
||||
slots = self.async_validate_slots(intent_obj.slots)
|
||||
|
||||
total_seconds = _get_total_seconds(slots)
|
||||
timer = _find_timer(hass, slots, intent_obj.device_id)
|
||||
timer_manager.add_time(timer.id, total_seconds)
|
||||
if not (
|
||||
intent_obj.device_id and timer_manager.is_timer_device(intent_obj.device_id)
|
||||
):
|
||||
# Fail early
|
||||
raise TimersNotSupportedError(intent_obj.device_id)
|
||||
|
||||
total_seconds = _get_total_seconds(slots)
|
||||
timer = _find_timer(hass, intent_obj.device_id, slots)
|
||||
timer_manager.add_time(timer.id, total_seconds)
|
||||
return intent_obj.create_response()
|
||||
|
||||
|
||||
@@ -796,10 +824,15 @@ class DecreaseTimerIntentHandler(intent.IntentHandler):
|
||||
timer_manager: TimerManager = hass.data[TIMER_DATA]
|
||||
slots = self.async_validate_slots(intent_obj.slots)
|
||||
|
||||
total_seconds = _get_total_seconds(slots)
|
||||
timer = _find_timer(hass, slots, intent_obj.device_id)
|
||||
timer_manager.remove_time(timer.id, total_seconds)
|
||||
if not (
|
||||
intent_obj.device_id and timer_manager.is_timer_device(intent_obj.device_id)
|
||||
):
|
||||
# Fail early
|
||||
raise TimersNotSupportedError(intent_obj.device_id)
|
||||
|
||||
total_seconds = _get_total_seconds(slots)
|
||||
timer = _find_timer(hass, intent_obj.device_id, slots)
|
||||
timer_manager.remove_time(timer.id, total_seconds)
|
||||
return intent_obj.create_response()
|
||||
|
||||
|
||||
@@ -820,9 +853,14 @@ class PauseTimerIntentHandler(intent.IntentHandler):
|
||||
timer_manager: TimerManager = hass.data[TIMER_DATA]
|
||||
slots = self.async_validate_slots(intent_obj.slots)
|
||||
|
||||
timer = _find_timer(hass, slots, intent_obj.device_id)
|
||||
timer_manager.pause_timer(timer.id)
|
||||
if not (
|
||||
intent_obj.device_id and timer_manager.is_timer_device(intent_obj.device_id)
|
||||
):
|
||||
# Fail early
|
||||
raise TimersNotSupportedError(intent_obj.device_id)
|
||||
|
||||
timer = _find_timer(hass, intent_obj.device_id, slots)
|
||||
timer_manager.pause_timer(timer.id)
|
||||
return intent_obj.create_response()
|
||||
|
||||
|
||||
@@ -843,9 +881,14 @@ class UnpauseTimerIntentHandler(intent.IntentHandler):
|
||||
timer_manager: TimerManager = hass.data[TIMER_DATA]
|
||||
slots = self.async_validate_slots(intent_obj.slots)
|
||||
|
||||
timer = _find_timer(hass, slots, intent_obj.device_id)
|
||||
timer_manager.unpause_timer(timer.id)
|
||||
if not (
|
||||
intent_obj.device_id and timer_manager.is_timer_device(intent_obj.device_id)
|
||||
):
|
||||
# Fail early
|
||||
raise TimersNotSupportedError(intent_obj.device_id)
|
||||
|
||||
timer = _find_timer(hass, intent_obj.device_id, slots)
|
||||
timer_manager.unpause_timer(timer.id)
|
||||
return intent_obj.create_response()
|
||||
|
||||
|
||||
@@ -863,10 +906,17 @@ class TimerStatusIntentHandler(intent.IntentHandler):
|
||||
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
|
||||
"""Handle the intent."""
|
||||
hass = intent_obj.hass
|
||||
timer_manager: TimerManager = hass.data[TIMER_DATA]
|
||||
slots = self.async_validate_slots(intent_obj.slots)
|
||||
|
||||
if not (
|
||||
intent_obj.device_id and timer_manager.is_timer_device(intent_obj.device_id)
|
||||
):
|
||||
# Fail early
|
||||
raise TimersNotSupportedError(intent_obj.device_id)
|
||||
|
||||
statuses: list[dict[str, Any]] = []
|
||||
for timer in _find_timers(hass, slots, intent_obj.device_id):
|
||||
for timer in _find_timers(hass, intent_obj.device_id, slots):
|
||||
total_seconds = timer.seconds_left
|
||||
|
||||
minutes, seconds = divmod(total_seconds, 60)
|
||||
|
@@ -58,7 +58,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
client.disable_request_retries()
|
||||
|
||||
async def async_get_data_from_api(
|
||||
api_coro: Callable[..., Coroutine[Any, Any, dict[str, Any]]],
|
||||
api_coro: Callable[[], Coroutine[Any, Any, dict[str, Any]]],
|
||||
) -> dict[str, Any]:
|
||||
"""Get data from a particular API coroutine."""
|
||||
try:
|
||||
|
@@ -447,7 +447,7 @@ class ISYBinarySensorHeartbeat(ISYNodeEntity, BinarySensorEntity, RestoreEntity)
|
||||
|
||||
self._node.control_events.subscribe(self._heartbeat_node_control_handler)
|
||||
|
||||
# Start the timer on bootup, so we can change from UNKNOWN to OFF
|
||||
# Start the timer on boot-up, so we can change from UNKNOWN to OFF
|
||||
self._restart_timer()
|
||||
|
||||
if (last_state := await self.async_get_last_state()) is not None:
|
||||
|
@@ -34,7 +34,7 @@ from .models import IsyData
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ISYSwitchEntityDescription(SwitchEntityDescription):
|
||||
"""Describes IST switch."""
|
||||
"""Describes ISY switch."""
|
||||
|
||||
# ISYEnableSwitchEntity does not support UNDEFINED or None,
|
||||
# restrict the type to str.
|
||||
|
@@ -396,7 +396,7 @@ class JellyfinSource(MediaSource):
|
||||
k.get(ITEM_KEY_NAME),
|
||||
),
|
||||
)
|
||||
return [await self._build_series(serie, False) for serie in series]
|
||||
return [await self._build_series(s, False) for s in series]
|
||||
|
||||
async def _build_series(
|
||||
self, series: dict[str, Any], include_children: bool
|
||||
|
@@ -5,41 +5,54 @@ from __future__ import annotations
|
||||
from hdate import Location
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_ELEVATION,
|
||||
CONF_LANGUAGE,
|
||||
CONF_LATITUDE,
|
||||
CONF_LONGITUDE,
|
||||
CONF_NAME,
|
||||
CONF_TIME_ZONE,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.discovery import async_load_platform
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
DOMAIN = "jewish_calendar"
|
||||
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
|
||||
|
||||
CONF_DIASPORA = "diaspora"
|
||||
CONF_LANGUAGE = "language"
|
||||
CONF_CANDLE_LIGHT_MINUTES = "candle_lighting_minutes_before_sunset"
|
||||
CONF_HAVDALAH_OFFSET_MINUTES = "havdalah_minutes_after_sunset"
|
||||
|
||||
CANDLE_LIGHT_DEFAULT = 18
|
||||
|
||||
DEFAULT_NAME = "Jewish Calendar"
|
||||
DEFAULT_CANDLE_LIGHT = 18
|
||||
DEFAULT_DIASPORA = False
|
||||
DEFAULT_HAVDALAH_OFFSET_MINUTES = 0
|
||||
DEFAULT_LANGUAGE = "english"
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
DOMAIN: vol.Schema(
|
||||
DOMAIN: vol.All(
|
||||
cv.deprecated(DOMAIN),
|
||||
{
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_DIASPORA, default=False): cv.boolean,
|
||||
vol.Optional(CONF_DIASPORA, default=DEFAULT_DIASPORA): cv.boolean,
|
||||
vol.Inclusive(CONF_LATITUDE, "coordinates"): cv.latitude,
|
||||
vol.Inclusive(CONF_LONGITUDE, "coordinates"): cv.longitude,
|
||||
vol.Optional(CONF_LANGUAGE, default="english"): vol.In(
|
||||
vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): vol.In(
|
||||
["hebrew", "english"]
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_CANDLE_LIGHT_MINUTES, default=CANDLE_LIGHT_DEFAULT
|
||||
CONF_CANDLE_LIGHT_MINUTES, default=DEFAULT_CANDLE_LIGHT
|
||||
): int,
|
||||
# Default of 0 means use 8.5 degrees / 'three_stars' time.
|
||||
vol.Optional(CONF_HAVDALAH_OFFSET_MINUTES, default=0): int,
|
||||
}
|
||||
vol.Optional(
|
||||
CONF_HAVDALAH_OFFSET_MINUTES,
|
||||
default=DEFAULT_HAVDALAH_OFFSET_MINUTES,
|
||||
): int,
|
||||
},
|
||||
)
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
@@ -72,37 +85,72 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
if DOMAIN not in config:
|
||||
return True
|
||||
|
||||
name = config[DOMAIN][CONF_NAME]
|
||||
language = config[DOMAIN][CONF_LANGUAGE]
|
||||
async_create_issue(
|
||||
hass,
|
||||
HOMEASSISTANT_DOMAIN,
|
||||
f"deprecated_yaml_{DOMAIN}",
|
||||
is_fixable=False,
|
||||
breaks_in_ha_version="2024.10.0",
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_yaml",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"integration_title": DEFAULT_NAME,
|
||||
},
|
||||
)
|
||||
|
||||
latitude = config[DOMAIN].get(CONF_LATITUDE, hass.config.latitude)
|
||||
longitude = config[DOMAIN].get(CONF_LONGITUDE, hass.config.longitude)
|
||||
diaspora = config[DOMAIN][CONF_DIASPORA]
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN]
|
||||
)
|
||||
)
|
||||
|
||||
candle_lighting_offset = config[DOMAIN][CONF_CANDLE_LIGHT_MINUTES]
|
||||
havdalah_offset = config[DOMAIN][CONF_HAVDALAH_OFFSET_MINUTES]
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Set up a configuration entry for Jewish calendar."""
|
||||
language = config_entry.data.get(CONF_LANGUAGE, DEFAULT_LANGUAGE)
|
||||
diaspora = config_entry.data.get(CONF_DIASPORA, DEFAULT_DIASPORA)
|
||||
candle_lighting_offset = config_entry.data.get(
|
||||
CONF_CANDLE_LIGHT_MINUTES, DEFAULT_CANDLE_LIGHT
|
||||
)
|
||||
havdalah_offset = config_entry.data.get(
|
||||
CONF_HAVDALAH_OFFSET_MINUTES, DEFAULT_HAVDALAH_OFFSET_MINUTES
|
||||
)
|
||||
|
||||
location = Location(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
timezone=hass.config.time_zone,
|
||||
name=hass.config.location_name,
|
||||
diaspora=diaspora,
|
||||
latitude=config_entry.data.get(CONF_LATITUDE, hass.config.latitude),
|
||||
longitude=config_entry.data.get(CONF_LONGITUDE, hass.config.longitude),
|
||||
altitude=config_entry.data.get(CONF_ELEVATION, hass.config.elevation),
|
||||
timezone=config_entry.data.get(CONF_TIME_ZONE, hass.config.time_zone),
|
||||
)
|
||||
|
||||
prefix = get_unique_prefix(
|
||||
location, language, candle_lighting_offset, havdalah_offset
|
||||
)
|
||||
hass.data[DOMAIN] = {
|
||||
"location": location,
|
||||
"name": name,
|
||||
hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = {
|
||||
"language": language,
|
||||
"diaspora": diaspora,
|
||||
"location": location,
|
||||
"candle_lighting_offset": candle_lighting_offset,
|
||||
"havdalah_offset": havdalah_offset,
|
||||
"diaspora": diaspora,
|
||||
"prefix": prefix,
|
||||
}
|
||||
|
||||
for platform in PLATFORMS:
|
||||
hass.async_create_task(async_load_platform(hass, platform, DOMAIN, {}, config))
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(
|
||||
config_entry, PLATFORMS
|
||||
)
|
||||
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(config_entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
|
@@ -6,6 +6,7 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import datetime as dt
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import hdate
|
||||
from hdate.zmanim import Zmanim
|
||||
@@ -14,20 +15,21 @@ from homeassistant.components.binary_sensor import (
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.helpers import event
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from . import DOMAIN
|
||||
from . import DEFAULT_NAME, DOMAIN
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JewishCalendarBinarySensorMixIns(BinarySensorEntityDescription):
|
||||
"""Binary Sensor description mixin class for Jewish Calendar."""
|
||||
|
||||
is_on: Callable[..., bool] = lambda _: False
|
||||
is_on: Callable[[Zmanim], bool] = lambda _: False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -63,15 +65,25 @@ async def async_setup_platform(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the Jewish Calendar binary sensor devices."""
|
||||
if discovery_info is None:
|
||||
return
|
||||
"""Set up the Jewish calendar binary sensors from YAML.
|
||||
|
||||
The YAML platform config is automatically
|
||||
imported to a config entry, this method can be removed
|
||||
when YAML support is removed.
|
||||
"""
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Jewish Calendar binary sensors."""
|
||||
async_add_entities(
|
||||
[
|
||||
JewishCalendarBinarySensor(hass.data[DOMAIN], description)
|
||||
for description in BINARY_SENSORS
|
||||
]
|
||||
JewishCalendarBinarySensor(
|
||||
hass.data[DOMAIN][config_entry.entry_id], description
|
||||
)
|
||||
for description in BINARY_SENSORS
|
||||
)
|
||||
|
||||
|
||||
@@ -83,13 +95,13 @@ class JewishCalendarBinarySensor(BinarySensorEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data: dict[str, str | bool | int | float],
|
||||
data: dict[str, Any],
|
||||
description: JewishCalendarBinarySensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the binary sensor."""
|
||||
self.entity_description = description
|
||||
self._attr_name = f"{data['name']} {description.name}"
|
||||
self._attr_unique_id = f"{data['prefix']}_{description.key}"
|
||||
self._attr_name = f"{DEFAULT_NAME} {description.name}"
|
||||
self._attr_unique_id = f'{data["prefix"]}_{description.key}'
|
||||
self._location = data["location"]
|
||||
self._hebrew = data["language"] == "hebrew"
|
||||
self._candle_lighting_offset = data["candle_lighting_offset"]
|
||||
|
135
homeassistant/components/jewish_calendar/config_flow.py
Normal file
135
homeassistant/components/jewish_calendar/config_flow.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""Config flow for Jewish calendar integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
import zoneinfo
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlowWithConfigEntry,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_ELEVATION,
|
||||
CONF_LANGUAGE,
|
||||
CONF_LATITUDE,
|
||||
CONF_LOCATION,
|
||||
CONF_LONGITUDE,
|
||||
CONF_TIME_ZONE,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.selector import (
|
||||
BooleanSelector,
|
||||
LocationSelector,
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
DOMAIN = "jewish_calendar"
|
||||
CONF_DIASPORA = "diaspora"
|
||||
CONF_CANDLE_LIGHT_MINUTES = "candle_lighting_minutes_before_sunset"
|
||||
CONF_HAVDALAH_OFFSET_MINUTES = "havdalah_minutes_after_sunset"
|
||||
DEFAULT_NAME = "Jewish Calendar"
|
||||
DEFAULT_CANDLE_LIGHT = 18
|
||||
DEFAULT_DIASPORA = False
|
||||
DEFAULT_HAVDALAH_OFFSET_MINUTES = 0
|
||||
DEFAULT_LANGUAGE = "english"
|
||||
|
||||
LANGUAGE = [
|
||||
SelectOptionDict(value="hebrew", label="Hebrew"),
|
||||
SelectOptionDict(value="english", label="English"),
|
||||
]
|
||||
|
||||
OPTIONS_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_CANDLE_LIGHT_MINUTES, default=DEFAULT_CANDLE_LIGHT): int,
|
||||
vol.Optional(
|
||||
CONF_HAVDALAH_OFFSET_MINUTES, default=DEFAULT_HAVDALAH_OFFSET_MINUTES
|
||||
): int,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_data_schema(hass: HomeAssistant) -> vol.Schema:
|
||||
default_location = {
|
||||
CONF_LATITUDE: hass.config.latitude,
|
||||
CONF_LONGITUDE: hass.config.longitude,
|
||||
}
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_DIASPORA, default=DEFAULT_DIASPORA): BooleanSelector(),
|
||||
vol.Required(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): SelectSelector(
|
||||
SelectSelectorConfig(options=LANGUAGE)
|
||||
),
|
||||
vol.Optional(CONF_LOCATION, default=default_location): LocationSelector(),
|
||||
vol.Optional(CONF_ELEVATION, default=hass.config.elevation): int,
|
||||
vol.Optional(CONF_TIME_ZONE, default=hass.config.time_zone): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=sorted(zoneinfo.available_timezones()),
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class JewishCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Jewish calendar."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowWithConfigEntry:
|
||||
"""Get the options flow for this handler."""
|
||||
return JewishCalendarOptionsFlowHandler(config_entry)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
if user_input is not None:
|
||||
if CONF_LOCATION in user_input:
|
||||
user_input[CONF_LATITUDE] = user_input[CONF_LOCATION][CONF_LATITUDE]
|
||||
user_input[CONF_LONGITUDE] = user_input[CONF_LOCATION][CONF_LONGITUDE]
|
||||
return self.async_create_entry(title=DEFAULT_NAME, data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
_get_data_schema(self.hass), user_input
|
||||
),
|
||||
)
|
||||
|
||||
async def async_step_import(
|
||||
self, import_config: ConfigType | None
|
||||
) -> ConfigFlowResult:
|
||||
"""Import a config entry from configuration.yaml."""
|
||||
return await self.async_step_user(import_config)
|
||||
|
||||
|
||||
class JewishCalendarOptionsFlowHandler(OptionsFlowWithConfigEntry):
|
||||
"""Handle Jewish Calendar options."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Manage the Jewish Calendar options."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
OPTIONS_SCHEMA, self.config_entry.options
|
||||
),
|
||||
)
|
@@ -2,8 +2,10 @@
|
||||
"domain": "jewish_calendar",
|
||||
"name": "Jewish Calendar",
|
||||
"codeowners": ["@tsvi"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/jewish_calendar",
|
||||
"iot_class": "calculated",
|
||||
"loggers": ["hdate"],
|
||||
"requirements": ["hdate==0.10.8"]
|
||||
"requirements": ["hdate==0.10.8"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
"""Platform to retrieve Jewish calendar information for Home Assistant."""
|
||||
"""Support for Jewish calendar sensors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -14,6 +14,7 @@ from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import SUN_EVENT_SUNSET
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
@@ -21,11 +22,11 @@ from homeassistant.helpers.sun import get_astral_event_date
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from . import DOMAIN
|
||||
from . import DEFAULT_NAME, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
INFO_SENSORS = (
|
||||
INFO_SENSORS: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
key="date",
|
||||
name="Date",
|
||||
@@ -53,10 +54,10 @@ INFO_SENSORS = (
|
||||
),
|
||||
)
|
||||
|
||||
TIME_SENSORS = (
|
||||
TIME_SENSORS: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
key="first_light",
|
||||
name="Alot Hashachar",
|
||||
name="Alot Hashachar", # codespell:ignore alot
|
||||
icon="mdi:weather-sunset-up",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
@@ -148,17 +149,24 @@ async def async_setup_platform(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the Jewish calendar sensor platform."""
|
||||
if discovery_info is None:
|
||||
return
|
||||
"""Set up the Jewish calendar sensors from YAML.
|
||||
|
||||
sensors = [
|
||||
JewishCalendarSensor(hass.data[DOMAIN], description)
|
||||
for description in INFO_SENSORS
|
||||
]
|
||||
The YAML platform config is automatically
|
||||
imported to a config entry, this method can be removed
|
||||
when YAML support is removed.
|
||||
"""
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Jewish calendar sensors ."""
|
||||
entry = hass.data[DOMAIN][config_entry.entry_id]
|
||||
sensors = [JewishCalendarSensor(entry, description) for description in INFO_SENSORS]
|
||||
sensors.extend(
|
||||
JewishCalendarTimeSensor(hass.data[DOMAIN], description)
|
||||
for description in TIME_SENSORS
|
||||
JewishCalendarTimeSensor(entry, description) for description in TIME_SENSORS
|
||||
)
|
||||
|
||||
async_add_entities(sensors)
|
||||
@@ -169,13 +177,13 @@ class JewishCalendarSensor(SensorEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data: dict[str, str | bool | int | float],
|
||||
data: dict[str, Any],
|
||||
description: SensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the Jewish calendar sensor."""
|
||||
self.entity_description = description
|
||||
self._attr_name = f"{data['name']} {description.name}"
|
||||
self._attr_unique_id = f"{data['prefix']}_{description.key}"
|
||||
self._attr_name = f"{DEFAULT_NAME} {description.name}"
|
||||
self._attr_unique_id = f'{data["prefix"]}_{description.key}'
|
||||
self._location = data["location"]
|
||||
self._hebrew = data["language"] == "hebrew"
|
||||
self._candle_lighting_offset = data["candle_lighting_offset"]
|
||||
@@ -202,8 +210,9 @@ class JewishCalendarSensor(SensorEntity):
|
||||
daytime_date = HDate(today, diaspora=self._diaspora, hebrew=self._hebrew)
|
||||
|
||||
# The Jewish day starts after darkness (called "tzais") and finishes at
|
||||
# sunset ("shkia"). The time in between is a gray area (aka "Bein
|
||||
# Hashmashot" - literally: "in between the sun and the moon").
|
||||
# sunset ("shkia"). The time in between is a gray area
|
||||
# (aka "Bein Hashmashot" # codespell:ignore
|
||||
# - literally: "in between the sun and the moon").
|
||||
|
||||
# For some sensors, it is more interesting to consider the date to be
|
||||
# tomorrow based on sunset ("shkia"), for others based on "tzais".
|
||||
|
37
homeassistant/components/jewish_calendar/strings.json
Normal file
37
homeassistant/components/jewish_calendar/strings.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"diaspora": "Outside of Israel?",
|
||||
"language": "Language for Holidays and Dates",
|
||||
"location": "[%key:common::config_flow::data::location%]",
|
||||
"elevation": "[%key:common::config_flow::data::elevation%]",
|
||||
"time_zone": "Time Zone"
|
||||
},
|
||||
"data_description": {
|
||||
"time_zone": "If you specify a location, make sure to specify the time zone for correct calendar times calculations"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Configure options for Jewish Calendar",
|
||||
"data": {
|
||||
"candle_lighting_minutes_before_sunset": "Minutes before sunset for candle lighthing",
|
||||
"havdalah_minutes_after_sunset": "Minutes after sunset for Havdalah"
|
||||
},
|
||||
"data_description": {
|
||||
"candle_lighting_minutes_before_sunset": "Defaults to 18 minutes. In Israel you probably want to use 20/30/40 depending on your location. Outside of Israel you probably want to use 18/24.",
|
||||
"havdalah_minutes_after_sunset": "Setting this to 0 means 36 minutes as fixed degrees (8.5°) will be used instead"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -3,7 +3,6 @@
|
||||
from collections.abc import Callable, Coroutine
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from bleak.backends.device import BLEDevice
|
||||
from lmcloud import LMCloud as LaMarzoccoClient
|
||||
@@ -132,11 +131,11 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
|
||||
self.lm.initialized = True
|
||||
|
||||
async def _async_handle_request(
|
||||
async def _async_handle_request[**_P](
|
||||
self,
|
||||
func: Callable[..., Coroutine[None, None, None]],
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
func: Callable[_P, Coroutine[None, None, None]],
|
||||
*args: _P.args,
|
||||
**kwargs: _P.kwargs,
|
||||
) -> None:
|
||||
"""Handle a request to the API."""
|
||||
try:
|
||||
|
@@ -7,14 +7,16 @@ import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.frontend import DATA_PANELS
|
||||
from homeassistant.const import CONF_FILENAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import collection, storage
|
||||
from homeassistant.helpers.json import json_bytes, json_fragment
|
||||
from homeassistant.util.yaml import Secrets, load_yaml_dict
|
||||
|
||||
from .const import (
|
||||
@@ -42,11 +44,13 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class LovelaceConfig(ABC):
|
||||
"""Base class for Lovelace config."""
|
||||
|
||||
def __init__(self, hass, url_path, config):
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, url_path: str | None, config: dict[str, Any] | None
|
||||
) -> None:
|
||||
"""Initialize Lovelace config."""
|
||||
self.hass = hass
|
||||
if config:
|
||||
self.config = {**config, CONF_URL_PATH: url_path}
|
||||
self.config: dict[str, Any] | None = {**config, CONF_URL_PATH: url_path}
|
||||
else:
|
||||
self.config = None
|
||||
|
||||
@@ -65,7 +69,7 @@ class LovelaceConfig(ABC):
|
||||
"""Return the config info."""
|
||||
|
||||
@abstractmethod
|
||||
async def async_load(self, force):
|
||||
async def async_load(self, force: bool) -> dict[str, Any]:
|
||||
"""Load config."""
|
||||
|
||||
async def async_save(self, config):
|
||||
@@ -77,7 +81,7 @@ class LovelaceConfig(ABC):
|
||||
raise HomeAssistantError("Not supported")
|
||||
|
||||
@callback
|
||||
def _config_updated(self):
|
||||
def _config_updated(self) -> None:
|
||||
"""Fire config updated event."""
|
||||
self.hass.bus.async_fire(EVENT_LOVELACE_UPDATED, {"url_path": self.url_path})
|
||||
|
||||
@@ -85,10 +89,10 @@ class LovelaceConfig(ABC):
|
||||
class LovelaceStorage(LovelaceConfig):
|
||||
"""Class to handle Storage based Lovelace config."""
|
||||
|
||||
def __init__(self, hass, config):
|
||||
def __init__(self, hass: HomeAssistant, config: dict[str, Any] | None) -> None:
|
||||
"""Initialize Lovelace config based on storage helper."""
|
||||
if config is None:
|
||||
url_path = None
|
||||
url_path: str | None = None
|
||||
storage_key = CONFIG_STORAGE_KEY_DEFAULT
|
||||
else:
|
||||
url_path = config[CONF_URL_PATH]
|
||||
@@ -96,8 +100,11 @@ class LovelaceStorage(LovelaceConfig):
|
||||
|
||||
super().__init__(hass, url_path, config)
|
||||
|
||||
self._store = storage.Store(hass, CONFIG_STORAGE_VERSION, storage_key)
|
||||
self._data = None
|
||||
self._store = storage.Store[dict[str, Any]](
|
||||
hass, CONFIG_STORAGE_VERSION, storage_key
|
||||
)
|
||||
self._data: dict[str, Any] | None = None
|
||||
self._json_config: json_fragment | None = None
|
||||
|
||||
@property
|
||||
def mode(self) -> str:
|
||||
@@ -106,27 +113,30 @@ class LovelaceStorage(LovelaceConfig):
|
||||
|
||||
async def async_get_info(self):
|
||||
"""Return the Lovelace storage info."""
|
||||
if self._data is None:
|
||||
await self._load()
|
||||
|
||||
if self._data["config"] is None:
|
||||
data = self._data or await self._load()
|
||||
if data["config"] is None:
|
||||
return {"mode": "auto-gen"}
|
||||
return _config_info(self.mode, data["config"])
|
||||
|
||||
return _config_info(self.mode, self._data["config"])
|
||||
|
||||
async def async_load(self, force):
|
||||
async def async_load(self, force: bool) -> dict[str, Any]:
|
||||
"""Load config."""
|
||||
if self.hass.config.recovery_mode:
|
||||
raise ConfigNotFound
|
||||
|
||||
if self._data is None:
|
||||
await self._load()
|
||||
|
||||
if (config := self._data["config"]) is None:
|
||||
data = self._data or await self._load()
|
||||
if (config := data["config"]) is None:
|
||||
raise ConfigNotFound
|
||||
|
||||
return config
|
||||
|
||||
async def async_json(self, force: bool) -> json_fragment:
|
||||
"""Return JSON representation of the config."""
|
||||
if self.hass.config.recovery_mode:
|
||||
raise ConfigNotFound
|
||||
if self._data is None:
|
||||
await self._load()
|
||||
return self._json_config or self._async_build_json()
|
||||
|
||||
async def async_save(self, config):
|
||||
"""Save config."""
|
||||
if self.hass.config.recovery_mode:
|
||||
@@ -135,6 +145,7 @@ class LovelaceStorage(LovelaceConfig):
|
||||
if self._data is None:
|
||||
await self._load()
|
||||
self._data["config"] = config
|
||||
self._json_config = None
|
||||
self._config_updated()
|
||||
await self._store.async_save(self._data)
|
||||
|
||||
@@ -145,25 +156,37 @@ class LovelaceStorage(LovelaceConfig):
|
||||
|
||||
await self._store.async_remove()
|
||||
self._data = None
|
||||
self._json_config = None
|
||||
self._config_updated()
|
||||
|
||||
async def _load(self):
|
||||
async def _load(self) -> dict[str, Any]:
|
||||
"""Load the config."""
|
||||
data = await self._store.async_load()
|
||||
self._data = data if data else {"config": None}
|
||||
return self._data
|
||||
|
||||
@callback
|
||||
def _async_build_json(self) -> json_fragment:
|
||||
"""Build JSON representation of the config."""
|
||||
if self._data is None or self._data["config"] is None:
|
||||
raise ConfigNotFound
|
||||
self._json_config = json_fragment(json_bytes(self._data["config"]))
|
||||
return self._json_config
|
||||
|
||||
|
||||
class LovelaceYAML(LovelaceConfig):
|
||||
"""Class to handle YAML-based Lovelace config."""
|
||||
|
||||
def __init__(self, hass, url_path, config):
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, url_path: str | None, config: dict[str, Any] | None
|
||||
) -> None:
|
||||
"""Initialize the YAML config."""
|
||||
super().__init__(hass, url_path, config)
|
||||
|
||||
self.path = hass.config.path(
|
||||
config[CONF_FILENAME] if config else LOVELACE_CONFIG_FILE
|
||||
)
|
||||
self._cache = None
|
||||
self._cache: tuple[dict[str, Any], float, json_fragment] | None = None
|
||||
|
||||
@property
|
||||
def mode(self) -> str:
|
||||
@@ -182,23 +205,35 @@ class LovelaceYAML(LovelaceConfig):
|
||||
|
||||
return _config_info(self.mode, config)
|
||||
|
||||
async def async_load(self, force):
|
||||
async def async_load(self, force: bool) -> dict[str, Any]:
|
||||
"""Load config."""
|
||||
is_updated, config = await self.hass.async_add_executor_job(
|
||||
config, json = await self._async_load_or_cached(force)
|
||||
return config
|
||||
|
||||
async def async_json(self, force: bool) -> json_fragment:
|
||||
"""Return JSON representation of the config."""
|
||||
config, json = await self._async_load_or_cached(force)
|
||||
return json
|
||||
|
||||
async def _async_load_or_cached(
|
||||
self, force: bool
|
||||
) -> tuple[dict[str, Any], json_fragment]:
|
||||
"""Load the config or return a cached version."""
|
||||
is_updated, config, json = await self.hass.async_add_executor_job(
|
||||
self._load_config, force
|
||||
)
|
||||
if is_updated:
|
||||
self._config_updated()
|
||||
return config
|
||||
return config, json
|
||||
|
||||
def _load_config(self, force):
|
||||
def _load_config(self, force: bool) -> tuple[bool, dict[str, Any], json_fragment]:
|
||||
"""Load the actual config."""
|
||||
# Check for a cached version of the config
|
||||
if not force and self._cache is not None:
|
||||
config, last_update = self._cache
|
||||
config, last_update, json = self._cache
|
||||
modtime = os.path.getmtime(self.path)
|
||||
if config and last_update > modtime:
|
||||
return False, config
|
||||
return False, config, json
|
||||
|
||||
is_updated = self._cache is not None
|
||||
|
||||
@@ -209,8 +244,9 @@ class LovelaceYAML(LovelaceConfig):
|
||||
except FileNotFoundError:
|
||||
raise ConfigNotFound from None
|
||||
|
||||
self._cache = (config, time.time())
|
||||
return is_updated, config
|
||||
json = json_fragment(json_bytes(config))
|
||||
self._cache = (config, time.time(), json)
|
||||
return is_updated, config, json
|
||||
|
||||
|
||||
def _config_info(mode, config):
|
||||
|
@@ -11,6 +11,7 @@ from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.json import json_fragment
|
||||
|
||||
from .const import CONF_URL_PATH, DOMAIN, ConfigNotFound
|
||||
from .dashboard import LovelaceStorage
|
||||
@@ -86,9 +87,9 @@ async def websocket_lovelace_config(
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
config: LovelaceStorage,
|
||||
) -> None:
|
||||
) -> json_fragment:
|
||||
"""Send Lovelace UI config over WebSocket configuration."""
|
||||
return await config.async_load(msg["force"])
|
||||
return await config.async_json(msg["force"])
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@@ -137,7 +138,7 @@ def websocket_lovelace_dashboards(
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Delete Lovelace UI configuration."""
|
||||
"""Send Lovelace dashboard configuration."""
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
[
|
||||
|
@@ -52,7 +52,10 @@ DEFAULT_TRANSITION = 0.2
|
||||
# sw version (attributeKey 0/40/10)
|
||||
TRANSITION_BLOCKLIST = (
|
||||
(4488, 514, "1.0", "1.0.0"),
|
||||
(4488, 260, "1.0", "1.0.0"),
|
||||
(5010, 769, "3.0", "1.0.0"),
|
||||
(4999, 25057, "1.0", "27.0"),
|
||||
(4448, 36866, "V1", "V1.0.0.5"),
|
||||
)
|
||||
|
||||
|
||||
|
@@ -243,7 +243,7 @@ class MikrotikData:
|
||||
return []
|
||||
|
||||
|
||||
class MikrotikDataUpdateCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-enforce-coordinator-module
|
||||
class MikrotikDataUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
"""Mikrotik Hub Object."""
|
||||
|
||||
def __init__(
|
||||
|
@@ -2,26 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
import aiohttp
|
||||
from moehlenhoff_alpha2 import Alpha2Base
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
from .coordinator import Alpha2BaseCoordinator
|
||||
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR]
|
||||
|
||||
UPDATE_INTERVAL = timedelta(seconds=60)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up a config entry."""
|
||||
@@ -51,114 +42,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Handle options update."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
class Alpha2BaseCoordinator(DataUpdateCoordinator[dict[str, dict]]): # pylint: disable=hass-enforce-coordinator-module
|
||||
"""Keep the base instance in one place and centralize the update."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, base: Alpha2Base) -> None:
|
||||
"""Initialize Alpha2Base data updater."""
|
||||
self.base = base
|
||||
super().__init__(
|
||||
hass=hass,
|
||||
logger=_LOGGER,
|
||||
name="alpha2_base",
|
||||
update_interval=UPDATE_INTERVAL,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, dict[str, dict]]:
|
||||
"""Fetch the latest data from the source."""
|
||||
await self.base.update_data()
|
||||
return {
|
||||
"heat_areas": {ha["ID"]: ha for ha in self.base.heat_areas if ha.get("ID")},
|
||||
"heat_controls": {
|
||||
hc["ID"]: hc for hc in self.base.heat_controls if hc.get("ID")
|
||||
},
|
||||
"io_devices": {io["ID"]: io for io in self.base.io_devices if io.get("ID")},
|
||||
}
|
||||
|
||||
def get_cooling(self) -> bool:
|
||||
"""Return if cooling mode is enabled."""
|
||||
return self.base.cooling
|
||||
|
||||
async def async_set_cooling(self, enabled: bool) -> None:
|
||||
"""Enable or disable cooling mode."""
|
||||
await self.base.set_cooling(enabled)
|
||||
self.async_update_listeners()
|
||||
|
||||
async def async_set_target_temperature(
|
||||
self, heat_area_id: str, target_temperature: float
|
||||
) -> None:
|
||||
"""Set the target temperature of the given heat area."""
|
||||
_LOGGER.debug(
|
||||
"Setting target temperature of heat area %s to %0.1f",
|
||||
heat_area_id,
|
||||
target_temperature,
|
||||
)
|
||||
|
||||
update_data = {"T_TARGET": target_temperature}
|
||||
is_cooling = self.get_cooling()
|
||||
heat_area_mode = self.data["heat_areas"][heat_area_id]["HEATAREA_MODE"]
|
||||
if heat_area_mode == 1:
|
||||
if is_cooling:
|
||||
update_data["T_COOL_DAY"] = target_temperature
|
||||
else:
|
||||
update_data["T_HEAT_DAY"] = target_temperature
|
||||
elif heat_area_mode == 2:
|
||||
if is_cooling:
|
||||
update_data["T_COOL_NIGHT"] = target_temperature
|
||||
else:
|
||||
update_data["T_HEAT_NIGHT"] = target_temperature
|
||||
|
||||
try:
|
||||
await self.base.update_heat_area(heat_area_id, update_data)
|
||||
except aiohttp.ClientError as http_err:
|
||||
raise HomeAssistantError(
|
||||
"Failed to set target temperature, communication error with alpha2 base"
|
||||
) from http_err
|
||||
self.data["heat_areas"][heat_area_id].update(update_data)
|
||||
self.async_update_listeners()
|
||||
|
||||
async def async_set_heat_area_mode(
|
||||
self, heat_area_id: str, heat_area_mode: int
|
||||
) -> None:
|
||||
"""Set the mode of the given heat area."""
|
||||
# HEATAREA_MODE: 0=Auto, 1=Tag, 2=Nacht
|
||||
if heat_area_mode not in (0, 1, 2):
|
||||
raise ValueError(f"Invalid heat area mode: {heat_area_mode}")
|
||||
_LOGGER.debug(
|
||||
"Setting mode of heat area %s to %d",
|
||||
heat_area_id,
|
||||
heat_area_mode,
|
||||
)
|
||||
try:
|
||||
await self.base.update_heat_area(
|
||||
heat_area_id, {"HEATAREA_MODE": heat_area_mode}
|
||||
)
|
||||
except aiohttp.ClientError as http_err:
|
||||
raise HomeAssistantError(
|
||||
"Failed to set heat area mode, communication error with alpha2 base"
|
||||
) from http_err
|
||||
|
||||
self.data["heat_areas"][heat_area_id]["HEATAREA_MODE"] = heat_area_mode
|
||||
is_cooling = self.get_cooling()
|
||||
if heat_area_mode == 1:
|
||||
if is_cooling:
|
||||
self.data["heat_areas"][heat_area_id]["T_TARGET"] = self.data[
|
||||
"heat_areas"
|
||||
][heat_area_id]["T_COOL_DAY"]
|
||||
else:
|
||||
self.data["heat_areas"][heat_area_id]["T_TARGET"] = self.data[
|
||||
"heat_areas"
|
||||
][heat_area_id]["T_HEAT_DAY"]
|
||||
elif heat_area_mode == 2:
|
||||
if is_cooling:
|
||||
self.data["heat_areas"][heat_area_id]["T_TARGET"] = self.data[
|
||||
"heat_areas"
|
||||
][heat_area_id]["T_COOL_NIGHT"]
|
||||
else:
|
||||
self.data["heat_areas"][heat_area_id]["T_TARGET"] = self.data[
|
||||
"heat_areas"
|
||||
][heat_area_id]["T_HEAT_NIGHT"]
|
||||
|
||||
self.async_update_listeners()
|
||||
|
@@ -10,8 +10,8 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import Alpha2BaseCoordinator
|
||||
from .const import DOMAIN
|
||||
from .coordinator import Alpha2BaseCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
|
@@ -8,8 +8,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import Alpha2BaseCoordinator
|
||||
from .const import DOMAIN
|
||||
from .coordinator import Alpha2BaseCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
|
@@ -15,8 +15,8 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import Alpha2BaseCoordinator
|
||||
from .const import DOMAIN, PRESET_AUTO, PRESET_DAY, PRESET_NIGHT
|
||||
from .coordinator import Alpha2BaseCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
128
homeassistant/components/moehlenhoff_alpha2/coordinator.py
Normal file
128
homeassistant/components/moehlenhoff_alpha2/coordinator.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Coordinator for the Moehlenhoff Alpha2."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
import aiohttp
|
||||
from moehlenhoff_alpha2 import Alpha2Base
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
UPDATE_INTERVAL = timedelta(seconds=60)
|
||||
|
||||
|
||||
class Alpha2BaseCoordinator(DataUpdateCoordinator[dict[str, dict]]):
|
||||
"""Keep the base instance in one place and centralize the update."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, base: Alpha2Base) -> None:
|
||||
"""Initialize Alpha2Base data updater."""
|
||||
self.base = base
|
||||
super().__init__(
|
||||
hass=hass,
|
||||
logger=_LOGGER,
|
||||
name="alpha2_base",
|
||||
update_interval=UPDATE_INTERVAL,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, dict[str, dict]]:
|
||||
"""Fetch the latest data from the source."""
|
||||
await self.base.update_data()
|
||||
return {
|
||||
"heat_areas": {ha["ID"]: ha for ha in self.base.heat_areas if ha.get("ID")},
|
||||
"heat_controls": {
|
||||
hc["ID"]: hc for hc in self.base.heat_controls if hc.get("ID")
|
||||
},
|
||||
"io_devices": {io["ID"]: io for io in self.base.io_devices if io.get("ID")},
|
||||
}
|
||||
|
||||
def get_cooling(self) -> bool:
|
||||
"""Return if cooling mode is enabled."""
|
||||
return self.base.cooling
|
||||
|
||||
async def async_set_cooling(self, enabled: bool) -> None:
|
||||
"""Enable or disable cooling mode."""
|
||||
await self.base.set_cooling(enabled)
|
||||
self.async_update_listeners()
|
||||
|
||||
async def async_set_target_temperature(
|
||||
self, heat_area_id: str, target_temperature: float
|
||||
) -> None:
|
||||
"""Set the target temperature of the given heat area."""
|
||||
_LOGGER.debug(
|
||||
"Setting target temperature of heat area %s to %0.1f",
|
||||
heat_area_id,
|
||||
target_temperature,
|
||||
)
|
||||
|
||||
update_data = {"T_TARGET": target_temperature}
|
||||
is_cooling = self.get_cooling()
|
||||
heat_area_mode = self.data["heat_areas"][heat_area_id]["HEATAREA_MODE"]
|
||||
if heat_area_mode == 1:
|
||||
if is_cooling:
|
||||
update_data["T_COOL_DAY"] = target_temperature
|
||||
else:
|
||||
update_data["T_HEAT_DAY"] = target_temperature
|
||||
elif heat_area_mode == 2:
|
||||
if is_cooling:
|
||||
update_data["T_COOL_NIGHT"] = target_temperature
|
||||
else:
|
||||
update_data["T_HEAT_NIGHT"] = target_temperature
|
||||
|
||||
try:
|
||||
await self.base.update_heat_area(heat_area_id, update_data)
|
||||
except aiohttp.ClientError as http_err:
|
||||
raise HomeAssistantError(
|
||||
"Failed to set target temperature, communication error with alpha2 base"
|
||||
) from http_err
|
||||
self.data["heat_areas"][heat_area_id].update(update_data)
|
||||
self.async_update_listeners()
|
||||
|
||||
async def async_set_heat_area_mode(
|
||||
self, heat_area_id: str, heat_area_mode: int
|
||||
) -> None:
|
||||
"""Set the mode of the given heat area."""
|
||||
# HEATAREA_MODE: 0=Auto, 1=Tag, 2=Nacht
|
||||
if heat_area_mode not in (0, 1, 2):
|
||||
raise ValueError(f"Invalid heat area mode: {heat_area_mode}")
|
||||
_LOGGER.debug(
|
||||
"Setting mode of heat area %s to %d",
|
||||
heat_area_id,
|
||||
heat_area_mode,
|
||||
)
|
||||
try:
|
||||
await self.base.update_heat_area(
|
||||
heat_area_id, {"HEATAREA_MODE": heat_area_mode}
|
||||
)
|
||||
except aiohttp.ClientError as http_err:
|
||||
raise HomeAssistantError(
|
||||
"Failed to set heat area mode, communication error with alpha2 base"
|
||||
) from http_err
|
||||
|
||||
self.data["heat_areas"][heat_area_id]["HEATAREA_MODE"] = heat_area_mode
|
||||
is_cooling = self.get_cooling()
|
||||
if heat_area_mode == 1:
|
||||
if is_cooling:
|
||||
self.data["heat_areas"][heat_area_id]["T_TARGET"] = self.data[
|
||||
"heat_areas"
|
||||
][heat_area_id]["T_COOL_DAY"]
|
||||
else:
|
||||
self.data["heat_areas"][heat_area_id]["T_TARGET"] = self.data[
|
||||
"heat_areas"
|
||||
][heat_area_id]["T_HEAT_DAY"]
|
||||
elif heat_area_mode == 2:
|
||||
if is_cooling:
|
||||
self.data["heat_areas"][heat_area_id]["T_TARGET"] = self.data[
|
||||
"heat_areas"
|
||||
][heat_area_id]["T_COOL_NIGHT"]
|
||||
else:
|
||||
self.data["heat_areas"][heat_area_id]["T_TARGET"] = self.data[
|
||||
"heat_areas"
|
||||
][heat_area_id]["T_HEAT_NIGHT"]
|
||||
|
||||
self.async_update_listeners()
|
@@ -7,8 +7,8 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import Alpha2BaseCoordinator
|
||||
from .const import DOMAIN
|
||||
from .coordinator import Alpha2BaseCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
|
@@ -414,7 +414,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
async def handle_webhook(
|
||||
hass: HomeAssistant, webhook_id: str, request: Request
|
||||
) -> None | Response:
|
||||
) -> Response | None:
|
||||
"""Handle webhook callback."""
|
||||
|
||||
try:
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user