Merge branch 'dev' into jbouwh-mqtt-device-discovery

This commit is contained in:
J. Nick Koston
2024-05-24 17:57:40 -10:00
committed by GitHub
457 changed files with 21864 additions and 12428 deletions

View File

@@ -471,7 +471,6 @@ omit =
homeassistant/components/frontier_silicon/browse_media.py homeassistant/components/frontier_silicon/browse_media.py
homeassistant/components/frontier_silicon/media_player.py homeassistant/components/frontier_silicon/media_player.py
homeassistant/components/futurenow/light.py homeassistant/components/futurenow/light.py
homeassistant/components/fyta/__init__.py
homeassistant/components/fyta/coordinator.py homeassistant/components/fyta/coordinator.py
homeassistant/components/fyta/entity.py homeassistant/components/fyta/entity.py
homeassistant/components/fyta/sensor.py homeassistant/components/fyta/sensor.py
@@ -730,7 +729,6 @@ omit =
homeassistant/components/lookin/sensor.py homeassistant/components/lookin/sensor.py
homeassistant/components/loqed/sensor.py homeassistant/components/loqed/sensor.py
homeassistant/components/luci/device_tracker.py homeassistant/components/luci/device_tracker.py
homeassistant/components/luftdaten/sensor.py
homeassistant/components/lupusec/__init__.py homeassistant/components/lupusec/__init__.py
homeassistant/components/lupusec/alarm_control_panel.py homeassistant/components/lupusec/alarm_control_panel.py
homeassistant/components/lupusec/binary_sensor.py homeassistant/components/lupusec/binary_sensor.py
@@ -805,10 +803,8 @@ omit =
homeassistant/components/mochad/switch.py homeassistant/components/mochad/switch.py
homeassistant/components/modem_callerid/button.py homeassistant/components/modem_callerid/button.py
homeassistant/components/modem_callerid/sensor.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/climate.py
homeassistant/components/moehlenhoff_alpha2/sensor.py homeassistant/components/moehlenhoff_alpha2/coordinator.py
homeassistant/components/monzo/__init__.py homeassistant/components/monzo/__init__.py
homeassistant/components/monzo/api.py homeassistant/components/monzo/api.py
homeassistant/components/motion_blinds/__init__.py homeassistant/components/motion_blinds/__init__.py
@@ -920,9 +916,8 @@ omit =
homeassistant/components/notion/util.py homeassistant/components/notion/util.py
homeassistant/components/nsw_fuel_station/sensor.py homeassistant/components/nsw_fuel_station/sensor.py
homeassistant/components/nuki/__init__.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/lock.py
homeassistant/components/nuki/sensor.py
homeassistant/components/nx584/alarm_control_panel.py homeassistant/components/nx584/alarm_control_panel.py
homeassistant/components/oasa_telematics/sensor.py homeassistant/components/oasa_telematics/sensor.py
homeassistant/components/obihai/__init__.py homeassistant/components/obihai/__init__.py
@@ -935,7 +930,7 @@ omit =
homeassistant/components/ohmconnect/sensor.py homeassistant/components/ohmconnect/sensor.py
homeassistant/components/ombi/* homeassistant/components/ombi/*
homeassistant/components/omnilogic/__init__.py homeassistant/components/omnilogic/__init__.py
homeassistant/components/omnilogic/common.py homeassistant/components/omnilogic/coordinator.py
homeassistant/components/omnilogic/sensor.py homeassistant/components/omnilogic/sensor.py
homeassistant/components/omnilogic/switch.py homeassistant/components/omnilogic/switch.py
homeassistant/components/ondilo_ico/__init__.py homeassistant/components/ondilo_ico/__init__.py
@@ -975,6 +970,7 @@ omit =
homeassistant/components/openuv/sensor.py homeassistant/components/openuv/sensor.py
homeassistant/components/openweathermap/__init__.py homeassistant/components/openweathermap/__init__.py
homeassistant/components/openweathermap/coordinator.py homeassistant/components/openweathermap/coordinator.py
homeassistant/components/openweathermap/repairs.py
homeassistant/components/openweathermap/sensor.py homeassistant/components/openweathermap/sensor.py
homeassistant/components/openweathermap/weather.py homeassistant/components/openweathermap/weather.py
homeassistant/components/opnsense/__init__.py homeassistant/components/opnsense/__init__.py
@@ -1097,6 +1093,7 @@ omit =
homeassistant/components/rainmachine/__init__.py homeassistant/components/rainmachine/__init__.py
homeassistant/components/rainmachine/binary_sensor.py homeassistant/components/rainmachine/binary_sensor.py
homeassistant/components/rainmachine/button.py homeassistant/components/rainmachine/button.py
homeassistant/components/rainmachine/coordinator.py
homeassistant/components/rainmachine/select.py homeassistant/components/rainmachine/select.py
homeassistant/components/rainmachine/sensor.py homeassistant/components/rainmachine/sensor.py
homeassistant/components/rainmachine/switch.py homeassistant/components/rainmachine/switch.py
@@ -1430,6 +1427,7 @@ omit =
homeassistant/components/thinkingcleaner/* homeassistant/components/thinkingcleaner/*
homeassistant/components/thomson/device_tracker.py homeassistant/components/thomson/device_tracker.py
homeassistant/components/tibber/__init__.py homeassistant/components/tibber/__init__.py
homeassistant/components/tibber/coordinator.py
homeassistant/components/tibber/sensor.py homeassistant/components/tibber/sensor.py
homeassistant/components/tikteck/light.py homeassistant/components/tikteck/light.py
homeassistant/components/tile/__init__.py homeassistant/components/tile/__init__.py
@@ -1707,10 +1705,6 @@ omit =
homeassistant/components/zeroconf/models.py homeassistant/components/zeroconf/models.py
homeassistant/components/zeroconf/usage.py homeassistant/components/zeroconf/usage.py
homeassistant/components/zestimate/sensor.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/cluster_handlers/*
homeassistant/components/zha/core/device.py homeassistant/components/zha/core/device.py
homeassistant/components/zha/core/gateway.py homeassistant/components/zha/core/gateway.py

View File

@@ -1,6 +1,6 @@
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.4 rev: v0.4.5
hooks: hooks:
- id: ruff - id: ruff
args: args:
@@ -8,11 +8,11 @@ repos:
- id: ruff-format - id: ruff-format
files: ^((homeassistant|pylint|script|tests)/.+)?[^/]+\.(py|pyi)$ files: ^((homeassistant|pylint|script|tests)/.+)?[^/]+\.(py|pyi)$
- repo: https://github.com/codespell-project/codespell - repo: https://github.com/codespell-project/codespell
rev: v2.2.6 rev: v2.3.0
hooks: hooks:
- id: codespell - id: codespell
args: 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" - --skip="./.*,*.csv,*.json,*.ambr"
- --quiet-level=2 - --quiet-level=2
exclude_types: [csv, json, html] exclude_types: [csv, json, html]

View File

@@ -163,6 +163,8 @@ build.json @home-assistant/supervisor
/tests/components/awair/ @ahayworth @danielsjf /tests/components/awair/ @ahayworth @danielsjf
/homeassistant/components/axis/ @Kane610 /homeassistant/components/axis/ @Kane610
/tests/components/axis/ @Kane610 /tests/components/axis/ @Kane610
/homeassistant/components/azure_data_explorer/ @kaareseras
/tests/components/azure_data_explorer/ @kaareseras
/homeassistant/components/azure_devops/ @timmo001 /homeassistant/components/azure_devops/ @timmo001
/tests/components/azure_devops/ @timmo001 /tests/components/azure_devops/ @timmo001
/homeassistant/components/azure_event_hub/ @eavanvalkenburg /homeassistant/components/azure_event_hub/ @eavanvalkenburg

View File

@@ -5,7 +5,7 @@
We as members, contributors, and leaders pledge to make participation in our We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender 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 nationality, personal appearance, race, religion, or sexual identity
and orientation. and orientation.

View File

@@ -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 .mfa_modules import MultiFactorAuthModule, auth_mfa_module_from_config
from .models import AuthFlowResult from .models import AuthFlowResult
from .providers import AuthProvider, LoginFlow, auth_provider_from_config from .providers import AuthProvider, LoginFlow, auth_provider_from_config
from .session import SessionManager
EVENT_USER_ADDED = "user_added" EVENT_USER_ADDED = "user_added"
EVENT_USER_UPDATED = "user_updated" EVENT_USER_UPDATED = "user_updated"
@@ -181,7 +180,6 @@ class AuthManager:
self._remove_expired_job = HassJob( self._remove_expired_job = HassJob(
self._async_remove_expired_refresh_tokens, job_type=HassJobType.Callback self._async_remove_expired_refresh_tokens, job_type=HassJobType.Callback
) )
self.session = SessionManager(hass, self)
async def async_setup(self) -> None: async def async_setup(self) -> None:
"""Set up the auth manager.""" """Set up the auth manager."""
@@ -192,7 +190,6 @@ class AuthManager:
) )
) )
self._async_track_next_refresh_token_expiration() self._async_track_next_refresh_token_expiration()
await self.session.async_setup()
@property @property
def auth_providers(self) -> list[AuthProvider]: def auth_providers(self) -> list[AuthProvider]:

View File

@@ -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)

View File

@@ -421,6 +421,9 @@ async def async_from_config_dict(
start = monotonic() start = monotonic()
hass.config_entries = config_entries.ConfigEntries(hass, config) 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) await async_load_base_functionality(hass)
# Set up core. # Set up core.

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/airgradient", "documentation": "https://www.home-assistant.io/integrations/airgradient",
"integration_type": "device", "integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["airgradient==0.4.0"], "requirements": ["airgradient==0.4.1"],
"zeroconf": ["_airgradient._tcp.local."] "zeroconf": ["_airgradient._tcp.local."]
} }

View File

@@ -66,7 +66,7 @@ SUPPORTED_VOICES: Final[list[str]] = [
"Hans", # German "Hans", # German
"Hiujin", # Chinese (Cantonese), Neural "Hiujin", # Chinese (Cantonese), Neural
"Ida", # Norwegian, Neural "Ida", # Norwegian, Neural
"Ines", # Portuguese, European "Ines", # Portuguese, European # codespell:ignore ines
"Ivy", # English "Ivy", # English
"Jacek", # Polish "Jacek", # Polish
"Jan", # Polish "Jan", # Polish

View File

@@ -39,6 +39,7 @@ ATTR_COURSE = "course"
ATTR_COMMENT = "comment" ATTR_COMMENT = "comment"
ATTR_FROM = "from" ATTR_FROM = "from"
ATTR_FORMAT = "format" ATTR_FORMAT = "format"
ATTR_OBJECT_NAME = "object_name"
ATTR_POS_AMBIGUITY = "posambiguity" ATTR_POS_AMBIGUITY = "posambiguity"
ATTR_SPEED = "speed" ATTR_SPEED = "speed"
@@ -50,7 +51,7 @@ DEFAULT_TIMEOUT = 30.0
FILTER_PORT = 14580 FILTER_PORT = 14580
MSG_FORMATS = ["compressed", "uncompressed", "mic-e"] MSG_FORMATS = ["compressed", "uncompressed", "mic-e", "object"]
PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend(
{ {
@@ -181,7 +182,10 @@ class AprsListenerThread(threading.Thread):
"""Receive message and process if position.""" """Receive message and process if position."""
_LOGGER.debug("APRS message received: %s", str(msg)) _LOGGER.debug("APRS message received: %s", str(msg))
if msg[ATTR_FORMAT] in MSG_FORMATS: 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] lat = msg[ATTR_LATITUDE]
lon = msg[ATTR_LONGITUDE] lon = msg[ATTR_LONGITUDE]

View File

@@ -162,7 +162,6 @@ from homeassistant.util import dt as dt_util
from . import indieauth, login_flow, mfa_setup_flow from . import indieauth, login_flow, mfa_setup_flow
DOMAIN = "auth" DOMAIN = "auth"
STRICT_CONNECTION_URL = "/auth/strict_connection/temp_token"
type StoreResultType = Callable[[str, Credentials], str] type StoreResultType = Callable[[str, Credentials], str]
type RetrieveResultType = Callable[[str, str], Credentials | None] 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(RevokeTokenView())
hass.http.register_view(LinkUserView(retrieve_result)) hass.http.register_view(LinkUserView(retrieve_result))
hass.http.register_view(OAuth2AuthorizeCallbackView()) 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_current_user)
websocket_api.async_register_command(hass, websocket_create_long_lived_access_token) websocket_api.async_register_command(hass, websocket_create_long_lived_access_token)
@@ -323,7 +321,6 @@ class TokenView(HomeAssistantView):
status_code=HTTPStatus.FORBIDDEN, status_code=HTTPStatus.FORBIDDEN,
) )
await hass.auth.session.async_create_session(request, refresh_token)
return self.json( return self.json(
{ {
"access_token": access_token, "access_token": access_token,
@@ -392,7 +389,6 @@ class TokenView(HomeAssistantView):
status_code=HTTPStatus.FORBIDDEN, status_code=HTTPStatus.FORBIDDEN,
) )
await hass.auth.session.async_create_session(request, refresh_token)
return self.json( return self.json(
{ {
"access_token": access_token, "access_token": access_token,
@@ -441,20 +437,6 @@ class LinkUserView(HomeAssistantView):
return self.json_message("User linked") 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 @callback
def _create_auth_code_store() -> tuple[StoreResultType, RetrieveResultType]: def _create_auth_code_store() -> tuple[StoreResultType, RetrieveResultType]:
"""Create an in memory store.""" """Create an in memory store."""

View 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)

View 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
)

View 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,
)

View 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)

View 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"]
}

View 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%]"
}
}
}

View File

@@ -20,6 +20,6 @@
"bluetooth-auto-recovery==1.4.2", "bluetooth-auto-recovery==1.4.2",
"bluetooth-data-tools==1.19.0", "bluetooth-data-tools==1.19.0",
"dbus-fast==2.21.3", "dbus-fast==2.21.3",
"habluetooth==3.1.0" "habluetooth==3.1.1"
] ]
} }

View File

@@ -103,3 +103,9 @@ class TurboJPEGSingleton:
"Error loading libturbojpeg; Camera snapshot performance will be sub-optimal" "Error loading libturbojpeg; Camera snapshot performance will be sub-optimal"
) )
TurboJPEGSingleton.__instance = False TurboJPEGSingleton.__instance = False
# TurboJPEG loads libraries that do blocking I/O.
# Initialize TurboJPEGSingleton in the executor to avoid
# blocking the event loop.
TurboJPEGSingleton.instance()

View File

@@ -7,14 +7,11 @@ from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta from datetime import datetime, timedelta
from enum import Enum from enum import Enum
from typing import cast from typing import cast
from urllib.parse import quote_plus, urljoin
from hass_nabucasa import Cloud from hass_nabucasa import Cloud
import voluptuous as vol import voluptuous as vol
from homeassistant.components import alexa, google_assistant, http from homeassistant.components import alexa, google_assistant
from homeassistant.components.auth import STRICT_CONNECTION_URL
from homeassistant.components.http.auth import async_sign_path
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
CONF_DESCRIPTION, CONF_DESCRIPTION,
@@ -24,21 +21,8 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_STOP,
Platform, Platform,
) )
from homeassistant.core import ( from homeassistant.core import Event, HassJob, HomeAssistant, ServiceCall, callback
Event, from homeassistant.exceptions import HomeAssistantError
HassJob,
HomeAssistant,
ServiceCall,
ServiceResponse,
SupportsResponse,
callback,
)
from homeassistant.exceptions import (
HomeAssistantError,
ServiceValidationError,
Unauthorized,
UnknownUser,
)
from homeassistant.helpers import config_validation as cv, entityfilter from homeassistant.helpers import config_validation as cv, entityfilter
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.discovery import async_load_platform
@@ -47,7 +31,6 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_send, async_dispatcher_send,
) )
from homeassistant.helpers.event import async_call_later 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.service import async_register_admin_service
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass from homeassistant.loader import bind_hass
@@ -418,50 +401,3 @@ def _setup_services(hass: HomeAssistant, prefs: CloudPreferences) -> None:
async_register_admin_service( async_register_admin_service(
hass, DOMAIN, SERVICE_REMOTE_DISCONNECT, _service_handler 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,
)

View File

@@ -250,7 +250,6 @@ class CloudClient(Interface):
"enabled": self._prefs.remote_enabled, "enabled": self._prefs.remote_enabled,
"instance_domain": self.cloud.remote.instance_domain, "instance_domain": self.cloud.remote.instance_domain,
"alias": self.cloud.remote.alias, "alias": self.cloud.remote.alias,
"strict_connection": self._prefs.strict_connection,
}, },
"version": HA_VERSION, "version": HA_VERSION,
"instance_id": self.prefs.instance_id, "instance_id": self.prefs.instance_id,

View File

@@ -33,7 +33,6 @@ PREF_GOOGLE_SETTINGS_VERSION = "google_settings_version"
PREF_TTS_DEFAULT_VOICE = "tts_default_voice" PREF_TTS_DEFAULT_VOICE = "tts_default_voice"
PREF_GOOGLE_CONNECTED = "google_connected" PREF_GOOGLE_CONNECTED = "google_connected"
PREF_REMOTE_ALLOW_REMOTE_ENABLE = "remote_allow_remote_enable" PREF_REMOTE_ALLOW_REMOTE_ENABLE = "remote_allow_remote_enable"
PREF_STRICT_CONNECTION = "strict_connection"
DEFAULT_TTS_DEFAULT_VOICE = ("en-US", "JennyNeural") DEFAULT_TTS_DEFAULT_VOICE = ("en-US", "JennyNeural")
DEFAULT_DISABLE_2FA = False DEFAULT_DISABLE_2FA = False
DEFAULT_ALEXA_REPORT_STATE = True DEFAULT_ALEXA_REPORT_STATE = True

View File

@@ -19,7 +19,7 @@ from hass_nabucasa.const import STATE_DISCONNECTED
from hass_nabucasa.voice import TTS_VOICES from hass_nabucasa.voice import TTS_VOICES
import voluptuous as vol import voluptuous as vol
from homeassistant.components import http, websocket_api from homeassistant.components import websocket_api
from homeassistant.components.alexa import ( from homeassistant.components.alexa import (
entities as alexa_entities, entities as alexa_entities,
errors as alexa_errors, errors as alexa_errors,
@@ -46,7 +46,6 @@ from .const import (
PREF_GOOGLE_REPORT_STATE, PREF_GOOGLE_REPORT_STATE,
PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_GOOGLE_SECURE_DEVICES_PIN,
PREF_REMOTE_ALLOW_REMOTE_ENABLE, PREF_REMOTE_ALLOW_REMOTE_ENABLE,
PREF_STRICT_CONNECTION,
PREF_TTS_DEFAULT_VOICE, PREF_TTS_DEFAULT_VOICE,
REQUEST_TIMEOUT, REQUEST_TIMEOUT,
) )
@@ -449,9 +448,6 @@ def validate_language_voice(value: tuple[str, str]) -> tuple[str, str]:
vol.Coerce(tuple), validate_language_voice vol.Coerce(tuple), validate_language_voice
), ),
vol.Optional(PREF_REMOTE_ALLOW_REMOTE_ENABLE): bool, vol.Optional(PREF_REMOTE_ALLOW_REMOTE_ENABLE): bool,
vol.Optional(PREF_STRICT_CONNECTION): vol.Coerce(
http.const.StrictConnectionMode
),
} }
) )
@websocket_api.async_response @websocket_api.async_response

View File

@@ -1,6 +1,5 @@
{ {
"services": { "services": {
"create_temporary_strict_connection_url": "mdi:login-variant",
"remote_connect": "mdi:cloud", "remote_connect": "mdi:cloud",
"remote_disconnect": "mdi:cloud-off" "remote_disconnect": "mdi:cloud-off"
} }

View File

@@ -10,7 +10,7 @@ from hass_nabucasa.voice import MAP_VOICE
from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.auth.models import User from homeassistant.auth.models import User
from homeassistant.components import http, webhook from homeassistant.components import webhook
from homeassistant.components.google_assistant.http import ( from homeassistant.components.google_assistant.http import (
async_get_users as async_get_google_assistant_users, async_get_users as async_get_google_assistant_users,
) )
@@ -44,7 +44,6 @@ from .const import (
PREF_INSTANCE_ID, PREF_INSTANCE_ID,
PREF_REMOTE_ALLOW_REMOTE_ENABLE, PREF_REMOTE_ALLOW_REMOTE_ENABLE,
PREF_REMOTE_DOMAIN, PREF_REMOTE_DOMAIN,
PREF_STRICT_CONNECTION,
PREF_TTS_DEFAULT_VOICE, PREF_TTS_DEFAULT_VOICE,
PREF_USERNAME, PREF_USERNAME,
) )
@@ -177,7 +176,6 @@ class CloudPreferences:
google_settings_version: int | UndefinedType = UNDEFINED, google_settings_version: int | UndefinedType = UNDEFINED,
google_connected: bool | UndefinedType = UNDEFINED, google_connected: bool | UndefinedType = UNDEFINED,
remote_allow_remote_enable: bool | UndefinedType = UNDEFINED, remote_allow_remote_enable: bool | UndefinedType = UNDEFINED,
strict_connection: http.const.StrictConnectionMode | UndefinedType = UNDEFINED,
) -> None: ) -> None:
"""Update user preferences.""" """Update user preferences."""
prefs = {**self._prefs} prefs = {**self._prefs}
@@ -197,7 +195,6 @@ class CloudPreferences:
(PREF_REMOTE_DOMAIN, remote_domain), (PREF_REMOTE_DOMAIN, remote_domain),
(PREF_GOOGLE_CONNECTED, google_connected), (PREF_GOOGLE_CONNECTED, google_connected),
(PREF_REMOTE_ALLOW_REMOTE_ENABLE, remote_allow_remote_enable), (PREF_REMOTE_ALLOW_REMOTE_ENABLE, remote_allow_remote_enable),
(PREF_STRICT_CONNECTION, strict_connection),
): ):
if value is not UNDEFINED: if value is not UNDEFINED:
prefs[key] = value prefs[key] = value
@@ -245,7 +242,6 @@ class CloudPreferences:
PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin, PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin,
PREF_REMOTE_ALLOW_REMOTE_ENABLE: self.remote_allow_remote_enable, PREF_REMOTE_ALLOW_REMOTE_ENABLE: self.remote_allow_remote_enable,
PREF_TTS_DEFAULT_VOICE: self.tts_default_voice, PREF_TTS_DEFAULT_VOICE: self.tts_default_voice,
PREF_STRICT_CONNECTION: self.strict_connection,
} }
@property @property
@@ -362,20 +358,6 @@ class CloudPreferences:
""" """
return self._prefs.get(PREF_TTS_DEFAULT_VOICE, DEFAULT_TTS_DEFAULT_VOICE) # type: ignore[no-any-return] 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: async def get_cloud_user(self) -> str:
"""Return ID of Home Assistant Cloud system user.""" """Return ID of Home Assistant Cloud system user."""
user = await self._load_cloud_user() user = await self._load_cloud_user()
@@ -433,5 +415,4 @@ class CloudPreferences:
PREF_REMOTE_DOMAIN: None, PREF_REMOTE_DOMAIN: None,
PREF_REMOTE_ALLOW_REMOTE_ENABLE: True, PREF_REMOTE_ALLOW_REMOTE_ENABLE: True,
PREF_USERNAME: username, PREF_USERNAME: username,
PREF_STRICT_CONNECTION: None,
} }

View File

@@ -5,14 +5,6 @@
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" "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": { "system_health": {
"info": { "info": {
"can_reach_cert_server": "Reach Certificate Server", "can_reach_cert_server": "Reach Certificate Server",
@@ -81,10 +73,6 @@
} }
}, },
"services": { "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": { "remote_connect": {
"name": "Remote connect", "name": "Remote connect",
"description": "Makes the instance UI accessible from outside of the local network by using Home Assistant Cloud." "description": "Makes the instance UI accessible from outside of the local network by using Home Assistant Cloud."

View File

@@ -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

View File

@@ -268,7 +268,7 @@ WALLETS = {
"XTZ": "XTZ", "XTZ": "XTZ",
"YER": "YER", "YER": "YER",
"YFI": "YFI", "YFI": "YFI",
"ZAR": "ZAR", "ZAR": "ZAR", # codespell:ignore zar
"ZEC": "ZEC", "ZEC": "ZEC",
"ZMW": "ZMW", "ZMW": "ZMW",
"ZRX": "ZRX", "ZRX": "ZRX",
@@ -550,7 +550,7 @@ RATES = {
"TRAC": "TRAC", "TRAC": "TRAC",
"TRB": "TRB", "TRB": "TRB",
"TRIBE": "TRIBE", "TRIBE": "TRIBE",
"TRU": "TRU", "TRU": "TRU", # codespell:ignore tru
"TRY": "TRY", "TRY": "TRY",
"TTD": "TTD", "TTD": "TTD",
"TWD": "TWD", "TWD": "TWD",
@@ -590,7 +590,7 @@ RATES = {
"YER": "YER", "YER": "YER",
"YFI": "YFI", "YFI": "YFI",
"YFII": "YFII", "YFII": "YFII",
"ZAR": "ZAR", "ZAR": "ZAR", # codespell:ignore zar
"ZEC": "ZEC", "ZEC": "ZEC",
"ZEN": "ZEN", "ZEN": "ZEN",
"ZMW": "ZMW", "ZMW": "ZMW",

View File

@@ -87,6 +87,7 @@ async def daikin_api_setup(
device = await Appliance.factory( device = await Appliance.factory(
host, session, key=key, uuid=uuid, password=password host, session, key=key, uuid=uuid, password=password
) )
_LOGGER.debug("Connection to %s successful", host)
except TimeoutError as err: except TimeoutError as err:
_LOGGER.debug("Connection to %s timed out", host) _LOGGER.debug("Connection to %s timed out", host)
raise ConfigEntryNotReady from err raise ConfigEntryNotReady from err

View File

@@ -51,6 +51,11 @@
"compressor_energy_consumption": { "compressor_energy_consumption": {
"name": "Compressor energy consumption" "name": "Compressor energy consumption"
} }
},
"switch": {
"toggle": {
"name": "Power"
}
} }
} }
} }

View File

@@ -347,7 +347,8 @@ AQARA_SINGLE_WALL_SWITCH = {
(CONF_DOUBLE_PRESS, CONF_TURN_ON): {CONF_EVENT: 1004}, (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 = { AQARA_MINI_SWITCH = {
(CONF_SHORT_PRESS, CONF_TURN_ON): {CONF_EVENT: 1002}, (CONF_SHORT_PRESS, CONF_TURN_ON): {CONF_EVENT: 1002},
(CONF_DOUBLE_PRESS, CONF_TURN_ON): {CONF_EVENT: 1004}, (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_QBKG11LM_MODEL: AQARA_SINGLE_WALL_SWITCH_QBKG11LM,
AQARA_SINGLE_WALL_SWITCH_WXKG03LM_MODEL: AQARA_SINGLE_WALL_SWITCH, AQARA_SINGLE_WALL_SWITCH_WXKG03LM_MODEL: AQARA_SINGLE_WALL_SWITCH,
AQARA_SINGLE_WALL_SWITCH_WXKG06LM_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_ROUND_SWITCH_MODEL: AQARA_ROUND_SWITCH,
AQARA_SQUARE_SWITCH_MODEL: AQARA_SQUARE_SWITCH, AQARA_SQUARE_SWITCH_MODEL: AQARA_SQUARE_SWITCH,
AQARA_SQUARE_SWITCH_WXKG11LM_2016_MODEL: AQARA_SQUARE_SWITCH_WXKG11LM_2016, AQARA_SQUARE_SWITCH_WXKG11LM_2016_MODEL: AQARA_SQUARE_SWITCH_WXKG11LM_2016,

View File

@@ -62,7 +62,7 @@ async def async_setup_entry(
await hass.async_add_executor_job( await hass.async_add_executor_job(
partial( partial(
HomeControl, HomeControl,
gateway_id=gateway_id, gateway_id=str(gateway_id),
mydevolo_instance=mydevolo, mydevolo_instance=mydevolo,
zeroconf_instance=zeroconf_instance, zeroconf_instance=zeroconf_instance,
) )

View File

@@ -3,9 +3,9 @@
Data is fetched from DWD: Data is fetched from DWD:
https://rcccm.dwd.de/DE/wetter/warnungen_aktuell/objekt_einbindung/objekteinbindung.html 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) Unwetterwarnungen (Stufe 3)
Warnungen vor markantem Wetter (Stufe 2) Warnungen vor markantem Wetter (Stufe 2) # codespell:ignore vor
Wetterwarnungen (Stufe 1) Wetterwarnungen (Stufe 1)
""" """

View File

@@ -17,6 +17,8 @@ SUPPORTED_LIFESPANS = (
LifeSpan.FILTER, LifeSpan.FILTER,
LifeSpan.LENS_BRUSH, LifeSpan.LENS_BRUSH,
LifeSpan.SIDE_BRUSH, LifeSpan.SIDE_BRUSH,
LifeSpan.UNIT_CARE,
LifeSpan.ROUND_MOP,
) )

View File

@@ -26,6 +26,12 @@
}, },
"reset_lifespan_side_brush": { "reset_lifespan_side_brush": {
"default": "mdi:broom" "default": "mdi:broom"
},
"reset_lifespan_unit_care": {
"default": "mdi:robot-vacuum"
},
"reset_lifespan_round_mop": {
"default": "mdi:broom"
} }
}, },
"event": { "event": {
@@ -63,6 +69,12 @@
"lifespan_side_brush": { "lifespan_side_brush": {
"default": "mdi:broom" "default": "mdi:broom"
}, },
"lifespan_unit_care": {
"default": "mdi:robot-vacuum"
},
"lifespan_round_mop": {
"default": "mdi:broom"
},
"network_ip": { "network_ip": {
"default": "mdi:ip-network-outline" "default": "mdi:ip-network-outline"
}, },

View File

@@ -58,6 +58,12 @@
"reset_lifespan_lens_brush": { "reset_lifespan_lens_brush": {
"name": "Reset lens brush lifespan" "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": { "reset_lifespan_side_brush": {
"name": "Reset side brushes lifespan" "name": "Reset side brushes lifespan"
} }
@@ -113,6 +119,12 @@
"lifespan_side_brush": { "lifespan_side_brush": {
"name": "Side brushes lifespan" "name": "Side brushes lifespan"
}, },
"lifespan_unit_care": {
"name": "Unit care lifespan"
},
"lifespan_round_mop": {
"name": "Round mop lifespan"
},
"network_ip": { "network_ip": {
"name": "IP address" "name": "IP address"
}, },

View File

@@ -26,7 +26,7 @@ async def async_get_device_diagnostics(
"device": { "device": {
"name": station.station, "name": station.station,
"model": station.model, "model": station.model,
"frequency": station.frequence, "frequency": station.frequence, # codespell:ignore frequence
"version": station.version, "version": station.version,
}, },
"raw": ecowitt.last_values[station_id], "raw": ecowitt.last_values[station_id],

View File

@@ -20,7 +20,7 @@ from . import data
from .const import DOMAIN from .const import DOMAIN
ENERGY_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.ENERGY,) ENERGY_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.ENERGY,)
ENERGY_USAGE_UNITS = { ENERGY_USAGE_UNITS: dict[str, tuple[UnitOfEnergy, ...]] = {
sensor.SensorDeviceClass.ENERGY: ( sensor.SensorDeviceClass.ENERGY: (
UnitOfEnergy.GIGA_JOULE, UnitOfEnergy.GIGA_JOULE,
UnitOfEnergy.KILO_WATT_HOUR, UnitOfEnergy.KILO_WATT_HOUR,
@@ -38,7 +38,7 @@ GAS_USAGE_DEVICE_CLASSES = (
sensor.SensorDeviceClass.ENERGY, sensor.SensorDeviceClass.ENERGY,
sensor.SensorDeviceClass.GAS, sensor.SensorDeviceClass.GAS,
) )
GAS_USAGE_UNITS = { GAS_USAGE_UNITS: dict[str, tuple[UnitOfEnergy | UnitOfVolume, ...]] = {
sensor.SensorDeviceClass.ENERGY: ( sensor.SensorDeviceClass.ENERGY: (
UnitOfEnergy.GIGA_JOULE, UnitOfEnergy.GIGA_JOULE,
UnitOfEnergy.KILO_WATT_HOUR, UnitOfEnergy.KILO_WATT_HOUR,
@@ -58,7 +58,7 @@ GAS_PRICE_UNITS = tuple(
GAS_UNIT_ERROR = "entity_unexpected_unit_gas" GAS_UNIT_ERROR = "entity_unexpected_unit_gas"
GAS_PRICE_UNIT_ERROR = "entity_unexpected_unit_gas_price" GAS_PRICE_UNIT_ERROR = "entity_unexpected_unit_gas_price"
WATER_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.WATER,) WATER_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.WATER,)
WATER_USAGE_UNITS = { WATER_USAGE_UNITS: dict[str, tuple[UnitOfVolume, ...]] = {
sensor.SensorDeviceClass.WATER: ( sensor.SensorDeviceClass.WATER: (
UnitOfVolume.CENTUM_CUBIC_FEET, UnitOfVolume.CENTUM_CUBIC_FEET,
UnitOfVolume.CUBIC_FEET, UnitOfVolume.CUBIC_FEET,
@@ -360,12 +360,14 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
source_result, 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( validate_calls.append(
functools.partial( functools.partial(
_async_validate_price_entity, _async_validate_price_entity,
hass, hass,
flow["entity_energy_price"], entity_energy_price,
source_result, source_result,
ENERGY_PRICE_UNITS, ENERGY_PRICE_UNITS,
ENERGY_PRICE_UNIT_ERROR, ENERGY_PRICE_UNIT_ERROR,
@@ -411,12 +413,14 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
source_result, 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( validate_calls.append(
functools.partial( functools.partial(
_async_validate_price_entity, _async_validate_price_entity,
hass, hass,
flow["entity_energy_price"], entity_energy_price,
source_result, source_result,
ENERGY_PRICE_UNITS, ENERGY_PRICE_UNITS,
ENERGY_PRICE_UNIT_ERROR, ENERGY_PRICE_UNIT_ERROR,
@@ -462,12 +466,12 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
source_result, 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( validate_calls.append(
functools.partial( functools.partial(
_async_validate_price_entity, _async_validate_price_entity,
hass, hass,
source["entity_energy_price"], entity_energy_price,
source_result, source_result,
GAS_PRICE_UNITS, GAS_PRICE_UNITS,
GAS_PRICE_UNIT_ERROR, GAS_PRICE_UNIT_ERROR,
@@ -513,12 +517,12 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
source_result, 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( validate_calls.append(
functools.partial( functools.partial(
_async_validate_price_entity, _async_validate_price_entity,
hass, hass,
source["entity_energy_price"], entity_energy_price,
source_result, source_result,
WATER_PRICE_UNITS, WATER_PRICE_UNITS,
WATER_PRICE_UNIT_ERROR, WATER_PRICE_UNIT_ERROR,

View File

@@ -44,7 +44,7 @@ class FibaroLock(FibaroDevice, LockEntity):
def unlock(self, **kwargs: Any) -> None: def unlock(self, **kwargs: Any) -> None:
"""Unlock the device.""" """Unlock the device."""
self.action("unsecure") self.action("unsecure") # codespell:ignore unsecure
self._attr_is_locked = False self._attr_is_locked = False
def update(self) -> None: def update(self) -> None:

View File

@@ -72,5 +72,5 @@ class FreeboxSwitch(SwitchEntity):
async def async_update(self) -> None: async def async_update(self) -> None:
"""Get the state and update it.""" """Get the state and update it."""
datas = await self._router.wifi.get_global_config() data = await self._router.wifi.get_global_config()
self._attr_is_on = bool(datas["enabled"]) self._attr_is_on = bool(data["enabled"])

View File

@@ -5,7 +5,6 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
import logging import logging
from typing import Any from typing import Any
from zoneinfo import ZoneInfo
from fyta_cli.fyta_connector import FytaConnector from fyta_cli.fyta_connector import FytaConnector
@@ -17,6 +16,7 @@ from homeassistant.const import (
Platform, Platform,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.util.dt import async_get_time_zone
from .const import CONF_EXPIRATION, DOMAIN from .const import CONF_EXPIRATION, DOMAIN
from .coordinator import FytaCoordinator 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] access_token: str = entry.data[CONF_ACCESS_TOKEN]
expiration: datetime = datetime.fromisoformat( expiration: datetime = datetime.fromisoformat(
entry.data[CONF_EXPIRATION] entry.data[CONF_EXPIRATION]
).astimezone(ZoneInfo(tz)) ).astimezone(await async_get_time_zone(tz))
fyta = FytaConnector(username, password, access_token, expiration, tz) fyta = FytaConnector(username, password, access_token, expiration, tz)

View File

@@ -15,7 +15,10 @@ from .helpers import async_send_text_commands, default_language_code
# https://support.google.com/assistant/answer/9071582?hl=en # https://support.google.com/assistant/answer/9071582?hl=en
LANG_TO_BROADCAST_COMMAND = { LANG_TO_BROADCAST_COMMAND = {
"en": ("broadcast {0}", "broadcast to {1} {0}"), "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}"), "es": ("Anuncia {0}", "Anuncia en {1} {0}"),
"fr": ("Diffuse {0}", "Diffuse dans {1} {0}"), "fr": ("Diffuse {0}", "Diffuse dans {1} {0}"),
"it": ("Trasmetti {0}", "Trasmetti in {1} {0}"), "it": ("Trasmetti {0}", "Trasmetti in {1} {0}"),

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from asyncio import timeout
from functools import partial from functools import partial
import mimetypes import mimetypes
from pathlib import Path 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]) genai.configure(api_key=entry.data[CONF_API_KEY])
try: try:
await hass.async_add_executor_job(partial(genai.list_models)) async with timeout(5.0):
except ClientError as err: next(await hass.async_add_executor_job(partial(genai.list_models)), None)
if err.reason == "API_KEY_INVALID": except (ClientError, TimeoutError) as err:
if isinstance(err, ClientError) and err.reason == "API_KEY_INVALID":
LOGGER.error("Invalid API key: %s", err) LOGGER.error("Invalid API key: %s", err)
return False return False
raise ConfigEntryNotReady(err) from err raise ConfigEntryNotReady(err) from err

View File

@@ -32,18 +32,24 @@ from homeassistant.helpers.selector import (
from .const import ( from .const import (
CONF_CHAT_MODEL, CONF_CHAT_MODEL,
CONF_DANGEROUS_BLOCK_THRESHOLD,
CONF_HARASSMENT_BLOCK_THRESHOLD,
CONF_HATE_BLOCK_THRESHOLD,
CONF_MAX_TOKENS, CONF_MAX_TOKENS,
CONF_PROMPT, CONF_PROMPT,
CONF_RECOMMENDED,
CONF_SEXUAL_BLOCK_THRESHOLD,
CONF_TEMPERATURE, CONF_TEMPERATURE,
CONF_TOP_K, CONF_TOP_K,
CONF_TOP_P, CONF_TOP_P,
DEFAULT_CHAT_MODEL,
DEFAULT_MAX_TOKENS,
DEFAULT_PROMPT, DEFAULT_PROMPT,
DEFAULT_TEMPERATURE,
DEFAULT_TOP_K,
DEFAULT_TOP_P,
DOMAIN, DOMAIN,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_HARM_BLOCK_THRESHOLD,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_K,
RECOMMENDED_TOP_P,
) )
_LOGGER = logging.getLogger(__name__) _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: async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
"""Validate the user input allows us to connect. """Validate the user input allows us to connect.
@@ -94,7 +106,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_create_entry( return self.async_create_entry(
title="Google Generative AI", title="Google Generative AI",
data=user_input, data=user_input,
options={CONF_LLM_HASS_API: llm.LLM_API_ASSIST}, options=RECOMMENDED_OPTIONS,
) )
return self.async_show_form( return self.async_show_form(
@@ -115,18 +127,32 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow):
def __init__(self, config_entry: ConfigEntry) -> None: def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize options flow.""" """Initialize options flow."""
self.config_entry = config_entry self.config_entry = config_entry
self.last_rendered_recommended = config_entry.options.get(
CONF_RECOMMENDED, False
)
async def async_step_init( async def async_step_init(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Manage the options.""" """Manage the options."""
options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options
if user_input is not None: if user_input is not None:
if user_input[CONF_LLM_HASS_API] == "none": if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended:
user_input.pop(CONF_LLM_HASS_API) if user_input[CONF_LLM_HASS_API] == "none":
return self.async_create_entry(title="", data=user_input) user_input.pop(CONF_LLM_HASS_API)
schema = await google_generative_ai_config_option_schema( return self.async_create_entry(title="", data=user_input)
self.hass, self.config_entry.options
) # 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( return self.async_show_form(
step_id="init", step_id="init",
data_schema=vol.Schema(schema), data_schema=vol.Schema(schema),
@@ -135,41 +161,16 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow):
async def google_generative_ai_config_option_schema( async def google_generative_ai_config_option_schema(
hass: HomeAssistant, hass: HomeAssistant,
options: MappingProxyType[str, Any], options: dict[str, Any] | MappingProxyType[str, Any],
) -> dict: ) -> dict:
"""Return a schema for Google Generative AI completion options.""" """Return a schema for Google Generative AI completion options."""
api_models = await hass.async_add_executor_job(partial(genai.list_models)) hass_apis: list[SelectOptionDict] = [
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] = [
SelectOptionDict( SelectOptionDict(
label="No control", label="No control",
value="none", value="none",
) )
] ]
apis.extend( hass_apis.extend(
SelectOptionDict( SelectOptionDict(
label=api.name, label=api.name,
value=api.id, value=api.id,
@@ -177,45 +178,119 @@ async def google_generative_ai_config_option_schema(
for api in llm.async_get_apis(hass) for api in llm.async_get_apis(hass)
) )
return { schema = {
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)),
vol.Optional( vol.Optional(
CONF_PROMPT, CONF_PROMPT,
description={"suggested_value": options.get(CONF_PROMPT)}, description={"suggested_value": options.get(CONF_PROMPT)},
default=DEFAULT_PROMPT, default=DEFAULT_PROMPT,
): TemplateSelector(), ): TemplateSelector(),
vol.Optional( vol.Optional(
CONF_TEMPERATURE, CONF_LLM_HASS_API,
description={"suggested_value": options.get(CONF_TEMPERATURE)}, description={"suggested_value": options.get(CONF_LLM_HASS_API)},
default=DEFAULT_TEMPERATURE, default="none",
): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), ): SelectSelector(SelectSelectorConfig(options=hass_apis)),
vol.Optional( vol.Required(
CONF_TOP_P, CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)
description={"suggested_value": options.get(CONF_TOP_P)}, ): bool,
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,
} }
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

View File

@@ -5,32 +5,21 @@ import logging
DOMAIN = "google_generative_ai_conversation" DOMAIN = "google_generative_ai_conversation"
LOGGER = logging.getLogger(__package__) LOGGER = logging.getLogger(__package__)
CONF_PROMPT = "prompt" CONF_PROMPT = "prompt"
DEFAULT_PROMPT = """This smart home is controlled by Home Assistant. DEFAULT_PROMPT = "Answer in plain text. Keep it simple and to the point."
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 %}
"""
CONF_RECOMMENDED = "recommended"
CONF_CHAT_MODEL = "chat_model" CONF_CHAT_MODEL = "chat_model"
DEFAULT_CHAT_MODEL = "models/gemini-pro" RECOMMENDED_CHAT_MODEL = "models/gemini-1.5-flash-latest"
CONF_TEMPERATURE = "temperature" CONF_TEMPERATURE = "temperature"
DEFAULT_TEMPERATURE = 0.9 RECOMMENDED_TEMPERATURE = 1.0
CONF_TOP_P = "top_p" CONF_TOP_P = "top_p"
DEFAULT_TOP_P = 1.0 RECOMMENDED_TOP_P = 0.95
CONF_TOP_K = "top_k" CONF_TOP_K = "top_k"
DEFAULT_TOP_K = 1 RECOMMENDED_TOP_K = 64
CONF_MAX_TOKENS = "max_tokens" CONF_MAX_TOKENS = "max_tokens"
DEFAULT_MAX_TOKENS = 150 RECOMMENDED_MAX_TOKENS = 150
DEFAULT_ALLOW_HASS_ACCESS = False 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"

View File

@@ -16,25 +16,30 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, TemplateError 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.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import ulid from homeassistant.util import ulid
from .const import ( from .const import (
CONF_CHAT_MODEL, CONF_CHAT_MODEL,
CONF_DANGEROUS_BLOCK_THRESHOLD,
CONF_HARASSMENT_BLOCK_THRESHOLD,
CONF_HATE_BLOCK_THRESHOLD,
CONF_MAX_TOKENS, CONF_MAX_TOKENS,
CONF_PROMPT, CONF_PROMPT,
CONF_SEXUAL_BLOCK_THRESHOLD,
CONF_TEMPERATURE, CONF_TEMPERATURE,
CONF_TOP_K, CONF_TOP_K,
CONF_TOP_P, CONF_TOP_P,
DEFAULT_CHAT_MODEL,
DEFAULT_MAX_TOKENS,
DEFAULT_PROMPT, DEFAULT_PROMPT,
DEFAULT_TEMPERATURE,
DEFAULT_TOP_K,
DEFAULT_TOP_P,
DOMAIN, DOMAIN,
LOGGER, 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 # Max number of back and forth with the LLM to generate a response
@@ -106,13 +111,20 @@ class GoogleGenerativeAIConversationEntity(
"""Google Generative AI conversation agent.""" """Google Generative AI conversation agent."""
_attr_has_entity_name = True _attr_has_entity_name = True
_attr_name = None
def __init__(self, entry: ConfigEntry) -> None: def __init__(self, entry: ConfigEntry) -> None:
"""Initialize the agent.""" """Initialize the agent."""
self.entry = entry self.entry = entry
self.history: dict[str, list[genai_types.ContentType]] = {} self.history: dict[str, list[genai_types.ContentType]] = {}
self._attr_name = entry.title
self._attr_unique_id = entry.entry_id 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 @property
def supported_languages(self) -> list[str] | Literal["*"]: 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()] 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 = 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={ generation_config={
"temperature": self.entry.options.get( "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_p": self.entry.options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
"top_k": self.entry.options.get(CONF_TOP_K, DEFAULT_TOP_K), "top_k": self.entry.options.get(CONF_TOP_K, RECOMMENDED_TOP_K),
"max_output_tokens": self.entry.options.get( "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, tools=tools or None,
@@ -180,7 +205,31 @@ class GoogleGenerativeAIConversationEntity(
messages = [{}, {}] messages = [{}, {}]
try: 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: except TemplateError as err:
LOGGER.error("Error rendering prompt: %s", err) LOGGER.error("Error rendering prompt: %s", err)
intent_response.async_set_error( intent_response.async_set_error(
@@ -221,7 +270,7 @@ class GoogleGenerativeAIConversationEntity(
if not chat_response.parts: if not chat_response.parts:
intent_response.async_set_error( intent_response.async_set_error(
intent.IntentResponseErrorCode.UNKNOWN, 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( return conversation.ConversationResult(
response=intent_response, conversation_id=conversation_id response=intent_response, conversation_id=conversation_id
@@ -267,18 +316,3 @@ class GoogleGenerativeAIConversationEntity(
return conversation.ConversationResult( return conversation.ConversationResult(
response=intent_response, conversation_id=conversation_id 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,
)

View File

@@ -18,13 +18,21 @@
"step": { "step": {
"init": { "init": {
"data": { "data": {
"prompt": "Prompt Template", "recommended": "Recommended model settings",
"prompt": "Instructions",
"chat_model": "[%key:common::generic::model%]", "chat_model": "[%key:common::generic::model%]",
"temperature": "Temperature", "temperature": "Temperature",
"top_p": "Top P", "top_p": "Top P",
"top_k": "Top K", "top_k": "Top K",
"max_tokens": "Maximum tokens to return in response", "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."
} }
} }
} }

View File

@@ -81,7 +81,7 @@ SUPPORT_LANGUAGES = [
"sv", "sv",
"sw", "sw",
"ta", "ta",
"te", "te", # codespell:ignore te
"th", "th",
"tl", "tl",
"tr", "tr",

View File

@@ -67,7 +67,7 @@ ALL_LANGUAGES = [
"sr", "sr",
"sv", "sv",
"ta", "ta",
"te", "te", # codespell:ignore te
"th", "th",
"tl", "tl",
"tr", "tr",

View File

@@ -3,6 +3,11 @@
from __future__ import annotations from __future__ import annotations
import asyncio 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.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import Platform
@@ -14,14 +19,29 @@ from .coordinator import GoveeLocalApiCoordinator
PLATFORMS: list[Platform] = [Platform.LIGHT] PLATFORMS: list[Platform] = [Platform.LIGHT]
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Govee light local from a config entry.""" """Set up Govee light local from a config entry."""
coordinator: GoveeLocalApiCoordinator = GoveeLocalApiCoordinator(hass=hass) 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() await coordinator.async_config_entry_first_refresh()

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from contextlib import suppress
import logging import logging
from govee_local_api import GoveeController from govee_local_api import GoveeController
@@ -39,7 +40,11 @@ async def _async_has_devices(hass: HomeAssistant) -> bool:
update_enabled=False, 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: try:
async with asyncio.timeout(delay=DISCOVERY_TIMEOUT): async with asyncio.timeout(delay=DISCOVERY_TIMEOUT):
@@ -49,7 +54,9 @@ async def _async_has_devices(hass: HomeAssistant) -> bool:
_LOGGER.debug("No devices found") _LOGGER.debug("No devices found")
devices_count = len(controller.devices) 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 return devices_count > 0

View File

@@ -1,5 +1,6 @@
"""Coordinator for Govee light local.""" """Coordinator for Govee light local."""
import asyncio
from collections.abc import Callable from collections.abc import Callable
import logging import logging
@@ -54,9 +55,9 @@ class GoveeLocalApiCoordinator(DataUpdateCoordinator[list[GoveeDevice]]):
"""Set discovery callback for automatic Govee light discovery.""" """Set discovery callback for automatic Govee light discovery."""
self._controller.set_device_discovered_callback(callback) self._controller.set_device_discovered_callback(callback)
def cleanup(self) -> None: def cleanup(self) -> asyncio.Event:
"""Stop and cleanup the cooridinator.""" """Stop and cleanup the cooridinator."""
self._controller.cleanup() return self._controller.cleanup()
async def turn_on(self, device: GoveeDevice) -> None: async def turn_on(self, device: GoveeDevice) -> None:
"""Turn on the light.""" """Turn on the light."""

View File

@@ -6,5 +6,5 @@
"dependencies": ["network"], "dependencies": ["network"],
"documentation": "https://www.home-assistant.io/integrations/govee_light_local", "documentation": "https://www.home-assistant.io/integrations/govee_light_local",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["govee-local-api==1.4.5"] "requirements": ["govee-local-api==1.5.0"]
} }

View File

@@ -34,7 +34,7 @@ class GuardianDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
entry: ConfigEntry, entry: ConfigEntry,
client: Client, client: Client,
api_name: str, 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, api_lock: asyncio.Lock,
valve_controller_uid: str, valve_controller_uid: str,
) -> None: ) -> None:

View File

@@ -275,7 +275,7 @@ def async_remove_addons_from_dev_reg(
dev_reg.async_remove_device(dev.id) 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.""" """Class to retrieve Hass.io status."""
def __init__( def __init__(

View File

@@ -59,6 +59,8 @@ CONF_MAX_WIDTH = "max_width"
CONF_STREAM_ADDRESS = "stream_address" CONF_STREAM_ADDRESS = "stream_address"
CONF_STREAM_SOURCE = "stream_source" CONF_STREAM_SOURCE = "stream_source"
CONF_SUPPORT_AUDIO = "support_audio" CONF_SUPPORT_AUDIO = "support_audio"
CONF_THRESHOLD_CO = "co_threshold"
CONF_THRESHOLD_CO2 = "co2_threshold"
CONF_VIDEO_CODEC = "video_codec" CONF_VIDEO_CODEC = "video_codec"
CONF_VIDEO_PROFILE_NAMES = "video_profile_names" CONF_VIDEO_PROFILE_NAMES = "video_profile_names"
CONF_VIDEO_MAP = "video_map" CONF_VIDEO_MAP = "video_map"

View File

@@ -41,6 +41,8 @@ from .const import (
CHAR_PM25_DENSITY, CHAR_PM25_DENSITY,
CHAR_SMOKE_DETECTED, CHAR_SMOKE_DETECTED,
CHAR_VOC_DENSITY, CHAR_VOC_DENSITY,
CONF_THRESHOLD_CO,
CONF_THRESHOLD_CO2,
PROP_CELSIUS, PROP_CELSIUS,
PROP_MAX_VALUE, PROP_MAX_VALUE,
PROP_MIN_VALUE, PROP_MIN_VALUE,
@@ -335,6 +337,10 @@ class CarbonMonoxideSensor(HomeAccessory):
SERV_CARBON_MONOXIDE_SENSOR, SERV_CARBON_MONOXIDE_SENSOR,
[CHAR_CARBON_MONOXIDE_LEVEL, CHAR_CARBON_MONOXIDE_PEAK_LEVEL], [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_level = serv_co.configure_char(CHAR_CARBON_MONOXIDE_LEVEL, value=0)
self.char_peak = serv_co.configure_char( self.char_peak = serv_co.configure_char(
CHAR_CARBON_MONOXIDE_PEAK_LEVEL, value=0 CHAR_CARBON_MONOXIDE_PEAK_LEVEL, value=0
@@ -353,7 +359,7 @@ class CarbonMonoxideSensor(HomeAccessory):
self.char_level.set_value(value) self.char_level.set_value(value)
if value > self.char_peak.value: if value > self.char_peak.value:
self.char_peak.set_value(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) self.char_detected.set_value(co_detected)
_LOGGER.debug("%s: Set to %d", self.entity_id, value) _LOGGER.debug("%s: Set to %d", self.entity_id, value)
@@ -371,6 +377,10 @@ class CarbonDioxideSensor(HomeAccessory):
SERV_CARBON_DIOXIDE_SENSOR, SERV_CARBON_DIOXIDE_SENSOR,
[CHAR_CARBON_DIOXIDE_LEVEL, CHAR_CARBON_DIOXIDE_PEAK_LEVEL], [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_level = serv_co2.configure_char(CHAR_CARBON_DIOXIDE_LEVEL, value=0)
self.char_peak = serv_co2.configure_char( self.char_peak = serv_co2.configure_char(
CHAR_CARBON_DIOXIDE_PEAK_LEVEL, value=0 CHAR_CARBON_DIOXIDE_PEAK_LEVEL, value=0
@@ -389,7 +399,7 @@ class CarbonDioxideSensor(HomeAccessory):
self.char_level.set_value(value) self.char_level.set_value(value)
if value > self.char_peak.value: if value > self.char_peak.value:
self.char_peak.set_value(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) self.char_detected.set_value(co2_detected)
_LOGGER.debug("%s: Set to %d", self.entity_id, value) _LOGGER.debug("%s: Set to %d", self.entity_id, value)

View File

@@ -72,6 +72,8 @@ from .const import (
CONF_STREAM_COUNT, CONF_STREAM_COUNT,
CONF_STREAM_SOURCE, CONF_STREAM_SOURCE,
CONF_SUPPORT_AUDIO, CONF_SUPPORT_AUDIO,
CONF_THRESHOLD_CO,
CONF_THRESHOLD_CO2,
CONF_VIDEO_CODEC, CONF_VIDEO_CODEC,
CONF_VIDEO_MAP, CONF_VIDEO_MAP,
CONF_VIDEO_PACKET_SIZE, 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 = { HOMEKIT_CHAR_TRANSLATIONS = {
0: " ", # nul 0: " ", # nul
@@ -297,6 +306,9 @@ def validate_entity_config(values: dict) -> dict[str, dict]:
elif domain == "cover": elif domain == "cover":
config = COVER_SCHEMA(config) config = COVER_SCHEMA(config)
elif domain == "sensor":
config = SENSOR_SCHEMA(config)
else: else:
config = BASIC_INFO_SCHEMA(config) config = BASIC_INFO_SCHEMA(config)

View File

@@ -44,14 +44,14 @@ BUTTON_ENTITIES: dict[str, HomeKitButtonEntityDescription] = {
name="Setup", name="Setup",
translation_key="setup", translation_key="setup",
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
write_value="#HAA@trcmd", write_value="#HAA@trcmd", # codespell:ignore haa
), ),
CharacteristicsTypes.VENDOR_HAA_UPDATE: HomeKitButtonEntityDescription( CharacteristicsTypes.VENDOR_HAA_UPDATE: HomeKitButtonEntityDescription(
key=CharacteristicsTypes.VENDOR_HAA_UPDATE, key=CharacteristicsTypes.VENDOR_HAA_UPDATE,
name="Update", name="Update",
device_class=ButtonDeviceClass.UPDATE, device_class=ButtonDeviceClass.UPDATE,
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
write_value="#HAA@trcmd", write_value="#HAA@trcmd", # codespell:ignore haa
), ),
CharacteristicsTypes.IDENTIFY: HomeKitButtonEntityDescription( CharacteristicsTypes.IDENTIFY: HomeKitButtonEntityDescription(
key=CharacteristicsTypes.IDENTIFY, key=CharacteristicsTypes.IDENTIFY,

View File

@@ -110,7 +110,7 @@ class HKDevice:
# A list of callbacks that turn HK characteristics into entities # A list of callbacks that turn HK characteristics into entities
self.char_factories: list[AddCharacteristicCb] = [] 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 # 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 # 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 # a lightbulb. And we don't want to forward a config entry twice

View File

@@ -2,6 +2,7 @@
from dataclasses import dataclass from dataclasses import dataclass
from aiohttp.client_exceptions import ClientConnectionError
import aiosomecomfort import aiosomecomfort
from homeassistant.config_entries import ConfigEntry 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.ConnectionError,
aiosomecomfort.device.ConnectionTimeout, aiosomecomfort.device.ConnectionTimeout,
aiosomecomfort.device.SomeComfortError, aiosomecomfort.device.SomeComfortError,
ClientConnectionError,
TimeoutError, TimeoutError,
) as ex: ) as ex:
raise ConfigEntryNotReady( raise ConfigEntryNotReady(

View File

@@ -54,6 +54,7 @@ class HoneywellConfigFlow(ConfigFlow, domain=DOMAIN):
"""Confirm re-authentication with Honeywell.""" """Confirm re-authentication with Honeywell."""
errors: dict[str, str] = {} errors: dict[str, str] = {}
assert self.entry is not None assert self.entry is not None
if user_input: if user_input:
try: try:
await self.is_valid( await self.is_valid(
@@ -63,14 +64,12 @@ class HoneywellConfigFlow(ConfigFlow, domain=DOMAIN):
except aiosomecomfort.AuthError: except aiosomecomfort.AuthError:
errors["base"] = "invalid_auth" errors["base"] = "invalid_auth"
except ( except (
aiosomecomfort.ConnectionError, aiosomecomfort.ConnectionError,
aiosomecomfort.ConnectionTimeout, aiosomecomfort.ConnectionTimeout,
TimeoutError, TimeoutError,
): ):
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
else: else:
return self.async_update_reload_and_abort( return self.async_update_reload_and_abort(
self.entry, self.entry,
@@ -83,7 +82,8 @@ class HoneywellConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form( return self.async_show_form(
step_id="reauth_confirm", step_id="reauth_confirm",
data_schema=self.add_suggested_values_to_schema( data_schema=self.add_suggested_values_to_schema(
REAUTH_SCHEMA, self.entry.data REAUTH_SCHEMA,
self.entry.data,
), ),
errors=errors, errors=errors,
description_placeholders={"name": "Honeywell"}, description_placeholders={"name": "Honeywell"},
@@ -91,7 +91,7 @@ class HoneywellConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_user(self, user_input=None) -> ConfigFlowResult: async def async_step_user(self, user_input=None) -> ConfigFlowResult:
"""Create config entry. Show the setup form to the user.""" """Create config entry. Show the setup form to the user."""
errors = {} errors: dict[str, str] = {}
if user_input is not None: if user_input is not None:
try: try:
await self.is_valid(**user_input) await self.is_valid(**user_input)
@@ -103,7 +103,6 @@ class HoneywellConfigFlow(ConfigFlow, domain=DOMAIN):
TimeoutError, TimeoutError,
): ):
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
if not errors: if not errors:
return self.async_create_entry( return self.async_create_entry(
title=DOMAIN, title=DOMAIN,
@@ -115,7 +114,9 @@ class HoneywellConfigFlow(ConfigFlow, domain=DOMAIN):
vol.Required(CONF_PASSWORD): str, vol.Required(CONF_PASSWORD): str,
} }
return self.async_show_form( 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: async def is_valid(self, **kwargs) -> bool:

View File

@@ -10,8 +10,7 @@ import os
import socket import socket
import ssl import ssl
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
from typing import Any, Final, Required, TypedDict, cast from typing import Any, Final, TypedDict, cast
from urllib.parse import quote_plus, urljoin
from aiohttp import web from aiohttp import web
from aiohttp.abc import AbstractStreamWriter from aiohttp.abc import AbstractStreamWriter
@@ -30,20 +29,8 @@ from yarl import URL
from homeassistant.components.network import async_get_source_ip from homeassistant.components.network import async_get_source_ip
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, SERVER_PORT from homeassistant.const import EVENT_HOMEASSISTANT_STOP, SERVER_PORT
from homeassistant.core import ( from homeassistant.core import Event, HomeAssistant
Event, from homeassistant.exceptions import HomeAssistantError
HomeAssistant,
ServiceCall,
ServiceResponse,
SupportsResponse,
callback,
)
from homeassistant.exceptions import (
HomeAssistantError,
ServiceValidationError,
Unauthorized,
UnknownUser,
)
from homeassistant.helpers import storage from homeassistant.helpers import storage
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.http import ( 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.async_ import create_eager_task
from homeassistant.util.json import json_loads 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 .ban import setup_bans
from .const import ( # noqa: F401 from .const import DOMAIN, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER # noqa: F401
DOMAIN,
KEY_HASS_REFRESH_TOKEN_ID,
KEY_HASS_USER,
StrictConnectionMode,
)
from .cors import setup_cors from .cors import setup_cors
from .decorators import require_admin # noqa: F401 from .decorators import require_admin # noqa: F401
from .forwarded import async_setup_forwarded 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_LOGIN_ATTEMPTS_THRESHOLD: Final = "login_attempts_threshold"
CONF_IP_BAN_ENABLED: Final = "ip_ban_enabled" CONF_IP_BAN_ENABLED: Final = "ip_ban_enabled"
CONF_SSL_PROFILE: Final = "ssl_profile" CONF_SSL_PROFILE: Final = "ssl_profile"
CONF_STRICT_CONNECTION: Final = "strict_connection"
SSL_MODERN: Final = "modern" SSL_MODERN: Final = "modern"
SSL_INTERMEDIATE: Final = "intermediate" SSL_INTERMEDIATE: Final = "intermediate"
@@ -146,9 +127,6 @@ HTTP_SCHEMA: Final = vol.All(
[SSL_INTERMEDIATE, SSL_MODERN] [SSL_INTERMEDIATE, SSL_MODERN]
), ),
vol.Optional(CONF_USE_X_FRAME_OPTIONS, default=True): cv.boolean, 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 login_attempts_threshold: int
ip_ban_enabled: bool ip_ban_enabled: bool
ssl_profile: str ssl_profile: str
strict_connection: Required[StrictConnectionMode]
@bind_hass @bind_hass
@@ -241,7 +218,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
login_threshold=login_threshold, login_threshold=login_threshold,
is_ban_enabled=is_ban_enabled, is_ban_enabled=is_ban_enabled,
use_x_frame_options=use_x_frame_options, use_x_frame_options=use_x_frame_options,
strict_connection_non_cloud=conf[CONF_STRICT_CONNECTION],
) )
async def stop_server(event: Event) -> None: 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 local_ip, host, server_port, ssl_certificate is not None
) )
_setup_services(hass, conf)
return True return True
@@ -356,7 +331,6 @@ class HomeAssistantHTTP:
login_threshold: int, login_threshold: int,
is_ban_enabled: bool, is_ban_enabled: bool,
use_x_frame_options: bool, use_x_frame_options: bool,
strict_connection_non_cloud: StrictConnectionMode,
) -> None: ) -> None:
"""Initialize the server.""" """Initialize the server."""
self.app[KEY_HASS] = self.hass self.app[KEY_HASS] = self.hass
@@ -373,7 +347,7 @@ class HomeAssistantHTTP:
if is_ban_enabled: if is_ban_enabled:
setup_bans(self.hass, self.app, login_threshold) 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_headers(self.app, use_x_frame_options)
setup_cors(self.app, cors_origins) 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) 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,
)

View File

@@ -4,18 +4,14 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from datetime import timedelta from datetime import timedelta
from http import HTTPStatus
from ipaddress import ip_address from ipaddress import ip_address
import logging import logging
import os
import secrets import secrets
import time import time
from typing import Any, Final from typing import Any, Final
from aiohttp import hdrs from aiohttp import hdrs
from aiohttp.web import Application, Request, Response, StreamResponse, middleware from aiohttp.web import Application, Request, StreamResponse, middleware
from aiohttp.web_exceptions import HTTPBadRequest
from aiohttp_session import session_middleware
import jwt import jwt
from jwt import api_jws from jwt import api_jws
from yarl import URL 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.auth.models import User
from homeassistant.components import websocket_api from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import singleton
from homeassistant.helpers.http import current_request from homeassistant.helpers.http import current_request
from homeassistant.helpers.json import json_bytes from homeassistant.helpers.json import json_bytes
from homeassistant.helpers.network import is_cloud_connection from homeassistant.helpers.network import is_cloud_connection
from homeassistant.helpers.storage import Store from homeassistant.helpers.storage import Store
from homeassistant.util.network import is_local from homeassistant.util.network import is_local
from .const import ( from .const import KEY_AUTHENTICATED, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER
DOMAIN,
KEY_AUTHENTICATED,
KEY_HASS_REFRESH_TOKEN_ID,
KEY_HASS_USER,
StrictConnectionMode,
)
from .session import HomeAssistantCookieStorage
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -51,11 +39,6 @@ SAFE_QUERY_PARAMS: Final = ["height", "width"]
STORAGE_VERSION = 1 STORAGE_VERSION = 1
STORAGE_KEY = "http.auth" STORAGE_KEY = "http.auth"
CONTENT_USER_NAME = "Home Assistant Content" 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 @callback
@@ -137,7 +120,6 @@ def async_user_not_allowed_do_auth(
async def async_setup_auth( async def async_setup_auth(
hass: HomeAssistant, hass: HomeAssistant,
app: Application, app: Application,
strict_connection_mode_non_cloud: StrictConnectionMode,
) -> None: ) -> None:
"""Create auth middleware for the app.""" """Create auth middleware for the app."""
store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY) 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 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 @callback
def async_validate_auth_header(request: Request) -> bool: def async_validate_auth_header(request: Request) -> bool:
"""Test authorization header against access token. """Test authorization header against access token.
@@ -252,37 +230,6 @@ async def async_setup_auth(
authenticated = True authenticated = True
auth_type = "signed request" 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): if authenticated and _LOGGER.isEnabledFor(logging.DEBUG):
_LOGGER.debug( _LOGGER.debug(
"Authenticated %s for %s using %s", "Authenticated %s for %s using %s",
@@ -294,69 +241,4 @@ async def async_setup_auth(
request[KEY_AUTHENTICATED] = authenticated request[KEY_AUTHENTICATED] = authenticated
return await handler(request) return await handler(request)
app.middlewares.append(session_middleware(HomeAssistantCookieStorage(hass)))
app.middlewares.append(auth_middleware) 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)

View File

@@ -1,6 +1,5 @@
"""HTTP specific constants.""" """HTTP specific constants."""
from enum import StrEnum
from typing import Final from typing import Final
from homeassistant.helpers.http import KEY_AUTHENTICATED, KEY_HASS # noqa: F401 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_USER: Final = "hass_user"
KEY_HASS_REFRESH_TOKEN_ID: Final = "hass_refresh_token_id" 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"

View File

@@ -1,5 +0,0 @@
{
"services": {
"create_temporary_strict_connection_url": "mdi:login-variant"
}
}

View File

@@ -1 +0,0 @@
create_temporary_strict_connection_url: ~

View File

@@ -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

View File

@@ -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."
}
}
}

View File

@@ -27,7 +27,7 @@ class HomeAssistantTCPSite(web.BaseSite):
def __init__( def __init__(
self, self,
runner: web.BaseRunner, runner: web.BaseRunner,
host: None | str | list[str], host: str | list[str] | None,
port: int, port: int,
*, *,
ssl_context: SSLContext | None = None, ssl_context: SSLContext | None = None,

View File

@@ -303,7 +303,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = {
key="rsrp", key="rsrp",
translation_key="rsrp", translation_key="rsrp",
device_class=SensorDeviceClass.SIGNAL_STRENGTH, 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), icon_fn=lambda x: signal_icon((-110, -95, -80), x),
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
@@ -313,7 +313,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = {
key="rsrq", key="rsrq",
translation_key="rsrq", translation_key="rsrq",
device_class=SensorDeviceClass.SIGNAL_STRENGTH, 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), icon_fn=lambda x: signal_icon((-11, -8, -5), x),
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
@@ -333,7 +333,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = {
key="sinr", key="sinr",
translation_key="sinr", translation_key="sinr",
device_class=SensorDeviceClass.SIGNAL_STRENGTH, 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), icon_fn=lambda x: signal_icon((0, 5, 10), x),
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,

View File

@@ -1,6 +1,7 @@
"""The constants for the Husqvarna Automower integration.""" """The constants for the Husqvarna Automower integration."""
DOMAIN = "husqvarna_automower" DOMAIN = "husqvarna_automower"
EXECUTION_TIME_DELAY = 5
NAME = "Husqvarna Automower" NAME = "Husqvarna Automower"
OAUTH2_AUTHORIZE = "https://api.authentication.husqvarnagroup.dev/v1/oauth2/authorize" OAUTH2_AUTHORIZE = "https://api.authentication.husqvarnagroup.dev/v1/oauth2/authorize"
OAUTH2_TOKEN = "https://api.authentication.husqvarnagroup.dev/v1/oauth2/token" OAUTH2_TOKEN = "https://api.authentication.husqvarnagroup.dev/v1/oauth2/token"

View File

@@ -12,13 +12,13 @@ from aioautomower.session import AutomowerSession
from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.config_entries import ConfigEntry 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.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN from .const import DOMAIN, EXECUTION_TIME_DELAY
from .coordinator import AutomowerDataUpdateCoordinator from .coordinator import AutomowerDataUpdateCoordinator
from .entity import AutomowerControlEntity from .entity import AutomowerControlEntity
@@ -52,10 +52,6 @@ async def async_set_work_area_cutting_height(
await coordinator.api.commands.set_cutting_height_workarea( await coordinator.api.commands.set_cutting_height_workarea(
mower_id, int(cheight), work_area_id 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( async def async_set_cutting_height(
@@ -189,6 +185,7 @@ class AutomowerWorkAreaNumberEntity(AutomowerControlEntity, NumberEntity):
) -> None: ) -> None:
"""Set up AutomowerNumberEntity.""" """Set up AutomowerNumberEntity."""
super().__init__(mower_id, coordinator) super().__init__(mower_id, coordinator)
self.coordinator = coordinator
self.entity_description = description self.entity_description = description
self.work_area_id = work_area_id self.work_area_id = work_area_id
self._attr_unique_id = f"{mower_id}_{work_area_id}_{description.key}" self._attr_unique_id = f"{mower_id}_{work_area_id}_{description.key}"
@@ -221,6 +218,11 @@ class AutomowerWorkAreaNumberEntity(AutomowerControlEntity, NumberEntity):
raise HomeAssistantError( raise HomeAssistantError(
f"Command couldn't be sent to the command queue: {exception}" f"Command couldn't be sent to the command queue: {exception}"
) from 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 @callback
@@ -238,10 +240,13 @@ def async_remove_entities(
for work_area_id in _work_areas: for work_area_id in _work_areas:
uid = f"{mower_id}_{work_area_id}_cutting_height_work_area" uid = f"{mower_id}_{work_area_id}_cutting_height_work_area"
active_work_areas.add(uid) active_work_areas.add(uid)
for entity_entry in er.async_entries_for_config_entry( for entity_entry in er.async_entries_for_config_entry(
entity_reg, config_entry.entry_id 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: entity_reg.async_remove(entity_entry.entity_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)

View File

@@ -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 collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass

View File

@@ -15,12 +15,13 @@ from aioautomower.model import (
from homeassistant.components.switch import SwitchEntity from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN from .const import DOMAIN, EXECUTION_TIME_DELAY
from .coordinator import AutomowerDataUpdateCoordinator from .coordinator import AutomowerDataUpdateCoordinator
from .entity import AutomowerControlEntity from .entity import AutomowerControlEntity
@@ -40,7 +41,6 @@ ERROR_STATES = [
MowerStates.STOPPED, MowerStates.STOPPED,
MowerStates.OFF, MowerStates.OFF,
] ]
EXECUTION_TIME = 5
async def async_setup_entry( async def async_setup_entry(
@@ -172,7 +172,7 @@ class AutomowerStayOutZoneSwitchEntity(AutomowerControlEntity, SwitchEntity):
else: else:
# As there are no updates from the websocket regarding stay out zone changes, # 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. # 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() await self.coordinator.async_request_refresh()
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
@@ -188,7 +188,7 @@ class AutomowerStayOutZoneSwitchEntity(AutomowerControlEntity, SwitchEntity):
else: else:
# As there are no updates from the websocket regarding stay out zone changes, # 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. # 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() await self.coordinator.async_request_refresh()
@@ -211,7 +211,8 @@ def async_remove_entities(
entity_reg, config_entry.entry_id entity_reg, config_entry.entry_id
): ):
if ( 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 split[-1] == "zones"
and entity_entry.unique_id not in active_zones and entity_entry.unique_id not in active_zones
): ):

View File

@@ -45,6 +45,8 @@ from .timers import (
IncreaseTimerIntentHandler, IncreaseTimerIntentHandler,
PauseTimerIntentHandler, PauseTimerIntentHandler,
StartTimerIntentHandler, StartTimerIntentHandler,
TimerEventType,
TimerInfo,
TimerManager, TimerManager,
TimerStatusIntentHandler, TimerStatusIntentHandler,
UnpauseTimerIntentHandler, UnpauseTimerIntentHandler,
@@ -57,6 +59,8 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
__all__ = [ __all__ = [
"async_register_timer_handler", "async_register_timer_handler",
"TimerInfo",
"TimerEventType",
"DOMAIN", "DOMAIN",
] ]

View File

@@ -29,6 +29,7 @@ _LOGGER = logging.getLogger(__name__)
TIMER_NOT_FOUND_RESPONSE = "timer_not_found" TIMER_NOT_FOUND_RESPONSE = "timer_not_found"
MULTIPLE_TIMERS_MATCHED_RESPONSE = "multiple_timers_matched" MULTIPLE_TIMERS_MATCHED_RESPONSE = "multiple_timers_matched"
NO_TIMER_SUPPORT_RESPONSE = "no_timer_support"
@dataclass @dataclass
@@ -44,7 +45,7 @@ class TimerInfo:
seconds: int seconds: int
"""Total number of seconds the timer should run for.""" """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.""" """Id of the device where the timer was set."""
start_hours: int | None start_hours: int | None
@@ -159,6 +160,17 @@ class MultipleTimersMatchedError(intent.IntentHandleError):
super().__init__("Multiple timers matched", MULTIPLE_TIMERS_MATCHED_RESPONSE) 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: class TimerManager:
"""Manager for intent timers.""" """Manager for intent timers."""
@@ -170,26 +182,36 @@ class TimerManager:
self.timers: dict[str, TimerInfo] = {} self.timers: dict[str, TimerInfo] = {}
self.timer_tasks: dict[str, asyncio.Task] = {} 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. """Register a timer handler.
Returns a callable to unregister. Returns a callable to unregister.
""" """
self.handlers.append(handler) self.handlers[device_id] = handler
return lambda: self.handlers.remove(handler)
def unregister() -> None:
self.handlers.pop(device_id)
return unregister
def start_timer( def start_timer(
self, self,
device_id: str,
hours: int | None, hours: int | None,
minutes: int | None, minutes: int | None,
seconds: int | None, seconds: int | None,
language: str, language: str,
device_id: str | None,
name: str | None = None, name: str | None = None,
) -> str: ) -> str:
"""Start a timer.""" """Start a timer."""
if not self.is_timer_device(device_id):
raise TimersNotSupportedError(device_id)
total_seconds = 0 total_seconds = 0
if hours is not None: if hours is not None:
total_seconds += 60 * 60 * hours total_seconds += 60 * 60 * hours
@@ -232,9 +254,7 @@ class TimerManager:
name=f"Timer {timer_id}", name=f"Timer {timer_id}",
) )
for handler in self.handlers: self.handlers[timer.device_id](TimerEventType.STARTED, timer)
handler(TimerEventType.STARTED, timer)
_LOGGER.debug( _LOGGER.debug(
"Timer started: id=%s, name=%s, hours=%s, minutes=%s, seconds=%s, device_id=%s", "Timer started: id=%s, name=%s, hours=%s, minutes=%s, seconds=%s, device_id=%s",
timer_id, timer_id,
@@ -272,9 +292,7 @@ class TimerManager:
timer.cancel() timer.cancel()
for handler in self.handlers: self.handlers[timer.device_id](TimerEventType.CANCELLED, timer)
handler(TimerEventType.CANCELLED, timer)
_LOGGER.debug( _LOGGER.debug(
"Timer cancelled: id=%s, name=%s, seconds_left=%s, device_id=%s", "Timer cancelled: id=%s, name=%s, seconds_left=%s, device_id=%s",
timer_id, timer_id,
@@ -302,8 +320,7 @@ class TimerManager:
name=f"Timer {timer_id}", name=f"Timer {timer_id}",
) )
for handler in self.handlers: self.handlers[timer.device_id](TimerEventType.UPDATED, timer)
handler(TimerEventType.UPDATED, timer)
if seconds > 0: if seconds > 0:
log_verb = "increased" log_verb = "increased"
@@ -340,9 +357,7 @@ class TimerManager:
task = self.timer_tasks.pop(timer_id) task = self.timer_tasks.pop(timer_id)
task.cancel() task.cancel()
for handler in self.handlers: self.handlers[timer.device_id](TimerEventType.UPDATED, timer)
handler(TimerEventType.UPDATED, timer)
_LOGGER.debug( _LOGGER.debug(
"Timer paused: id=%s, name=%s, seconds_left=%s, device_id=%s", "Timer paused: id=%s, name=%s, seconds_left=%s, device_id=%s",
timer_id, timer_id,
@@ -367,9 +382,7 @@ class TimerManager:
name=f"Timer {timer.id}", name=f"Timer {timer.id}",
) )
for handler in self.handlers: self.handlers[timer.device_id](TimerEventType.UPDATED, timer)
handler(TimerEventType.UPDATED, timer)
_LOGGER.debug( _LOGGER.debug(
"Timer unpaused: id=%s, name=%s, seconds_left=%s, device_id=%s", "Timer unpaused: id=%s, name=%s, seconds_left=%s, device_id=%s",
timer_id, timer_id,
@@ -383,9 +396,8 @@ class TimerManager:
timer = self.timers.pop(timer_id) timer = self.timers.pop(timer_id)
timer.finish() timer.finish()
for handler in self.handlers:
handler(TimerEventType.FINISHED, timer)
self.handlers[timer.device_id](TimerEventType.FINISHED, timer)
_LOGGER.debug( _LOGGER.debug(
"Timer finished: id=%s, name=%s, device_id=%s", "Timer finished: id=%s, name=%s, device_id=%s",
timer_id, timer_id,
@@ -393,24 +405,28 @@ class TimerManager:
timer.device_id, 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 @callback
def async_register_timer_handler( def async_register_timer_handler(
hass: HomeAssistant, handler: TimerHandler hass: HomeAssistant, device_id: str, handler: TimerHandler
) -> Callable[[], None]: ) -> Callable[[], None]:
"""Register a handler for timer events. """Register a handler for timer events.
Returns a callable to unregister. Returns a callable to unregister.
""" """
timer_manager: TimerManager = hass.data[TIMER_DATA] timer_manager: TimerManager = hass.data[TIMER_DATA]
return timer_manager.register_handler(handler) return timer_manager.register_handler(device_id, handler)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def _find_timer( def _find_timer(
hass: HomeAssistant, slots: dict[str, Any], device_id: str | None hass: HomeAssistant, device_id: str, slots: dict[str, Any]
) -> TimerInfo: ) -> TimerInfo:
"""Match a single timer with constraints or raise an error.""" """Match a single timer with constraints or raise an error."""
timer_manager: TimerManager = hass.data[TIMER_DATA] timer_manager: TimerManager = hass.data[TIMER_DATA]
@@ -479,7 +495,7 @@ def _find_timer(
return matching_timers[0] return matching_timers[0]
# Use device id # Use device id
if matching_timers and device_id: if matching_timers:
matching_device_timers = [ matching_device_timers = [
t for t in matching_timers if (t.device_id == device_id) t for t in matching_timers if (t.device_id == device_id)
] ]
@@ -528,7 +544,7 @@ def _find_timer(
def _find_timers( def _find_timers(
hass: HomeAssistant, slots: dict[str, Any], device_id: str | None hass: HomeAssistant, device_id: str, slots: dict[str, Any]
) -> list[TimerInfo]: ) -> list[TimerInfo]:
"""Match multiple timers with constraints or raise an error.""" """Match multiple timers with constraints or raise an error."""
timer_manager: TimerManager = hass.data[TIMER_DATA] timer_manager: TimerManager = hass.data[TIMER_DATA]
@@ -587,10 +603,6 @@ def _find_timers(
# No matches # No matches
return matching_timers 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 # Use device id to order remaining timers
device_registry = dr.async_get(hass) device_registry = dr.async_get(hass)
device = device_registry.async_get(device_id) device = device_registry.async_get(device_id)
@@ -702,6 +714,12 @@ class StartTimerIntentHandler(intent.IntentHandler):
timer_manager: TimerManager = hass.data[TIMER_DATA] timer_manager: TimerManager = hass.data[TIMER_DATA]
slots = self.async_validate_slots(intent_obj.slots) 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 name: str | None = None
if "name" in slots: if "name" in slots:
name = slots["name"]["value"] name = slots["name"]["value"]
@@ -719,11 +737,11 @@ class StartTimerIntentHandler(intent.IntentHandler):
seconds = int(slots["seconds"]["value"]) seconds = int(slots["seconds"]["value"])
timer_manager.start_timer( timer_manager.start_timer(
intent_obj.device_id,
hours, hours,
minutes, minutes,
seconds, seconds,
language=intent_obj.language, language=intent_obj.language,
device_id=intent_obj.device_id,
name=name, name=name,
) )
@@ -747,9 +765,14 @@ class CancelTimerIntentHandler(intent.IntentHandler):
timer_manager: TimerManager = hass.data[TIMER_DATA] timer_manager: TimerManager = hass.data[TIMER_DATA]
slots = self.async_validate_slots(intent_obj.slots) slots = self.async_validate_slots(intent_obj.slots)
timer = _find_timer(hass, slots, intent_obj.device_id) if not (
timer_manager.cancel_timer(timer.id) 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() return intent_obj.create_response()
@@ -771,10 +794,15 @@ class IncreaseTimerIntentHandler(intent.IntentHandler):
timer_manager: TimerManager = hass.data[TIMER_DATA] timer_manager: TimerManager = hass.data[TIMER_DATA]
slots = self.async_validate_slots(intent_obj.slots) slots = self.async_validate_slots(intent_obj.slots)
total_seconds = _get_total_seconds(slots) if not (
timer = _find_timer(hass, slots, intent_obj.device_id) intent_obj.device_id and timer_manager.is_timer_device(intent_obj.device_id)
timer_manager.add_time(timer.id, total_seconds) ):
# 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() return intent_obj.create_response()
@@ -796,10 +824,15 @@ class DecreaseTimerIntentHandler(intent.IntentHandler):
timer_manager: TimerManager = hass.data[TIMER_DATA] timer_manager: TimerManager = hass.data[TIMER_DATA]
slots = self.async_validate_slots(intent_obj.slots) slots = self.async_validate_slots(intent_obj.slots)
total_seconds = _get_total_seconds(slots) if not (
timer = _find_timer(hass, slots, intent_obj.device_id) intent_obj.device_id and timer_manager.is_timer_device(intent_obj.device_id)
timer_manager.remove_time(timer.id, total_seconds) ):
# 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() return intent_obj.create_response()
@@ -820,9 +853,14 @@ class PauseTimerIntentHandler(intent.IntentHandler):
timer_manager: TimerManager = hass.data[TIMER_DATA] timer_manager: TimerManager = hass.data[TIMER_DATA]
slots = self.async_validate_slots(intent_obj.slots) slots = self.async_validate_slots(intent_obj.slots)
timer = _find_timer(hass, slots, intent_obj.device_id) if not (
timer_manager.pause_timer(timer.id) 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() return intent_obj.create_response()
@@ -843,9 +881,14 @@ class UnpauseTimerIntentHandler(intent.IntentHandler):
timer_manager: TimerManager = hass.data[TIMER_DATA] timer_manager: TimerManager = hass.data[TIMER_DATA]
slots = self.async_validate_slots(intent_obj.slots) slots = self.async_validate_slots(intent_obj.slots)
timer = _find_timer(hass, slots, intent_obj.device_id) if not (
timer_manager.unpause_timer(timer.id) 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() return intent_obj.create_response()
@@ -863,10 +906,17 @@ class TimerStatusIntentHandler(intent.IntentHandler):
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
"""Handle the intent.""" """Handle the intent."""
hass = intent_obj.hass hass = intent_obj.hass
timer_manager: TimerManager = hass.data[TIMER_DATA]
slots = self.async_validate_slots(intent_obj.slots) 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]] = [] 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 total_seconds = timer.seconds_left
minutes, seconds = divmod(total_seconds, 60) minutes, seconds = divmod(total_seconds, 60)

View File

@@ -58,7 +58,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
client.disable_request_retries() client.disable_request_retries()
async def async_get_data_from_api( 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]: ) -> dict[str, Any]:
"""Get data from a particular API coroutine.""" """Get data from a particular API coroutine."""
try: try:

View File

@@ -447,7 +447,7 @@ class ISYBinarySensorHeartbeat(ISYNodeEntity, BinarySensorEntity, RestoreEntity)
self._node.control_events.subscribe(self._heartbeat_node_control_handler) 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() self._restart_timer()
if (last_state := await self.async_get_last_state()) is not None: if (last_state := await self.async_get_last_state()) is not None:

View File

@@ -34,7 +34,7 @@ from .models import IsyData
@dataclass(frozen=True) @dataclass(frozen=True)
class ISYSwitchEntityDescription(SwitchEntityDescription): class ISYSwitchEntityDescription(SwitchEntityDescription):
"""Describes IST switch.""" """Describes ISY switch."""
# ISYEnableSwitchEntity does not support UNDEFINED or None, # ISYEnableSwitchEntity does not support UNDEFINED or None,
# restrict the type to str. # restrict the type to str.

View File

@@ -396,7 +396,7 @@ class JellyfinSource(MediaSource):
k.get(ITEM_KEY_NAME), 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( async def _build_series(
self, series: dict[str, Any], include_children: bool self, series: dict[str, Any], include_children: bool

View File

@@ -5,41 +5,54 @@ from __future__ import annotations
from hdate import Location from hdate import Location
import voluptuous as vol import voluptuous as vol
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, Platform from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.core import HomeAssistant 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 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 from homeassistant.helpers.typing import ConfigType
DOMAIN = "jewish_calendar" DOMAIN = "jewish_calendar"
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
CONF_DIASPORA = "diaspora" CONF_DIASPORA = "diaspora"
CONF_LANGUAGE = "language"
CONF_CANDLE_LIGHT_MINUTES = "candle_lighting_minutes_before_sunset" CONF_CANDLE_LIGHT_MINUTES = "candle_lighting_minutes_before_sunset"
CONF_HAVDALAH_OFFSET_MINUTES = "havdalah_minutes_after_sunset" CONF_HAVDALAH_OFFSET_MINUTES = "havdalah_minutes_after_sunset"
CANDLE_LIGHT_DEFAULT = 18
DEFAULT_NAME = "Jewish Calendar" 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( 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_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_LATITUDE, "coordinates"): cv.latitude,
vol.Inclusive(CONF_LONGITUDE, "coordinates"): cv.longitude, 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"] ["hebrew", "english"]
), ),
vol.Optional( vol.Optional(
CONF_CANDLE_LIGHT_MINUTES, default=CANDLE_LIGHT_DEFAULT CONF_CANDLE_LIGHT_MINUTES, default=DEFAULT_CANDLE_LIGHT
): int, ): int,
# Default of 0 means use 8.5 degrees / 'three_stars' time. # 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, extra=vol.ALLOW_EXTRA,
@@ -72,37 +85,72 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
if DOMAIN not in config: if DOMAIN not in config:
return True return True
name = config[DOMAIN][CONF_NAME] async_create_issue(
language = config[DOMAIN][CONF_LANGUAGE] 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) hass.async_create_task(
longitude = config[DOMAIN].get(CONF_LONGITUDE, hass.config.longitude) hass.config_entries.flow.async_init(
diaspora = config[DOMAIN][CONF_DIASPORA] DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN]
)
)
candle_lighting_offset = config[DOMAIN][CONF_CANDLE_LIGHT_MINUTES] return True
havdalah_offset = config[DOMAIN][CONF_HAVDALAH_OFFSET_MINUTES]
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( location = Location(
latitude=latitude, name=hass.config.location_name,
longitude=longitude,
timezone=hass.config.time_zone,
diaspora=diaspora, 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( prefix = get_unique_prefix(
location, language, candle_lighting_offset, havdalah_offset location, language, candle_lighting_offset, havdalah_offset
) )
hass.data[DOMAIN] = { hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = {
"location": location,
"name": name,
"language": language, "language": language,
"diaspora": diaspora,
"location": location,
"candle_lighting_offset": candle_lighting_offset, "candle_lighting_offset": candle_lighting_offset,
"havdalah_offset": havdalah_offset, "havdalah_offset": havdalah_offset,
"diaspora": diaspora,
"prefix": prefix, "prefix": prefix,
} }
for platform in PLATFORMS: await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
hass.async_create_task(async_load_platform(hass, platform, DOMAIN, {}, config))
return True 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

View File

@@ -6,6 +6,7 @@ from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
import datetime as dt import datetime as dt
from datetime import datetime from datetime import datetime
from typing import Any
import hdate import hdate
from hdate.zmanim import Zmanim from hdate.zmanim import Zmanim
@@ -14,20 +15,21 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity, BinarySensorEntity,
BinarySensorEntityDescription, BinarySensorEntityDescription,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers import event from homeassistant.helpers import event
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
from . import DOMAIN from . import DEFAULT_NAME, DOMAIN
@dataclass(frozen=True) @dataclass(frozen=True)
class JewishCalendarBinarySensorMixIns(BinarySensorEntityDescription): class JewishCalendarBinarySensorMixIns(BinarySensorEntityDescription):
"""Binary Sensor description mixin class for Jewish Calendar.""" """Binary Sensor description mixin class for Jewish Calendar."""
is_on: Callable[..., bool] = lambda _: False is_on: Callable[[Zmanim], bool] = lambda _: False
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -63,15 +65,25 @@ async def async_setup_platform(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None, discovery_info: DiscoveryInfoType | None = None,
) -> None: ) -> None:
"""Set up the Jewish Calendar binary sensor devices.""" """Set up the Jewish calendar binary sensors from YAML.
if discovery_info is None:
return
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( async_add_entities(
[ JewishCalendarBinarySensor(
JewishCalendarBinarySensor(hass.data[DOMAIN], description) hass.data[DOMAIN][config_entry.entry_id], description
for description in BINARY_SENSORS )
] for description in BINARY_SENSORS
) )
@@ -83,13 +95,13 @@ class JewishCalendarBinarySensor(BinarySensorEntity):
def __init__( def __init__(
self, self,
data: dict[str, str | bool | int | float], data: dict[str, Any],
description: JewishCalendarBinarySensorEntityDescription, description: JewishCalendarBinarySensorEntityDescription,
) -> None: ) -> None:
"""Initialize the binary sensor.""" """Initialize the binary sensor."""
self.entity_description = description self.entity_description = description
self._attr_name = f"{data['name']} {description.name}" self._attr_name = f"{DEFAULT_NAME} {description.name}"
self._attr_unique_id = f"{data['prefix']}_{description.key}" self._attr_unique_id = f'{data["prefix"]}_{description.key}'
self._location = data["location"] self._location = data["location"]
self._hebrew = data["language"] == "hebrew" self._hebrew = data["language"] == "hebrew"
self._candle_lighting_offset = data["candle_lighting_offset"] self._candle_lighting_offset = data["candle_lighting_offset"]

View 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
),
)

View File

@@ -2,8 +2,10 @@
"domain": "jewish_calendar", "domain": "jewish_calendar",
"name": "Jewish Calendar", "name": "Jewish Calendar",
"codeowners": ["@tsvi"], "codeowners": ["@tsvi"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/jewish_calendar", "documentation": "https://www.home-assistant.io/integrations/jewish_calendar",
"iot_class": "calculated", "iot_class": "calculated",
"loggers": ["hdate"], "loggers": ["hdate"],
"requirements": ["hdate==0.10.8"] "requirements": ["hdate==0.10.8"],
"single_config_entry": true
} }

View File

@@ -1,4 +1,4 @@
"""Platform to retrieve Jewish calendar information for Home Assistant.""" """Support for Jewish calendar sensors."""
from __future__ import annotations from __future__ import annotations
@@ -14,6 +14,7 @@ from homeassistant.components.sensor import (
SensorEntity, SensorEntity,
SensorEntityDescription, SensorEntityDescription,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import SUN_EVENT_SUNSET from homeassistant.const import SUN_EVENT_SUNSET
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback 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 from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
from . import DOMAIN from . import DEFAULT_NAME, DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
INFO_SENSORS = ( INFO_SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription( SensorEntityDescription(
key="date", key="date",
name="Date", name="Date",
@@ -53,10 +54,10 @@ INFO_SENSORS = (
), ),
) )
TIME_SENSORS = ( TIME_SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription( SensorEntityDescription(
key="first_light", key="first_light",
name="Alot Hashachar", name="Alot Hashachar", # codespell:ignore alot
icon="mdi:weather-sunset-up", icon="mdi:weather-sunset-up",
), ),
SensorEntityDescription( SensorEntityDescription(
@@ -148,17 +149,24 @@ async def async_setup_platform(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None, discovery_info: DiscoveryInfoType | None = None,
) -> None: ) -> None:
"""Set up the Jewish calendar sensor platform.""" """Set up the Jewish calendar sensors from YAML.
if discovery_info is None:
return
sensors = [ The YAML platform config is automatically
JewishCalendarSensor(hass.data[DOMAIN], description) imported to a config entry, this method can be removed
for description in INFO_SENSORS 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( sensors.extend(
JewishCalendarTimeSensor(hass.data[DOMAIN], description) JewishCalendarTimeSensor(entry, description) for description in TIME_SENSORS
for description in TIME_SENSORS
) )
async_add_entities(sensors) async_add_entities(sensors)
@@ -169,13 +177,13 @@ class JewishCalendarSensor(SensorEntity):
def __init__( def __init__(
self, self,
data: dict[str, str | bool | int | float], data: dict[str, Any],
description: SensorEntityDescription, description: SensorEntityDescription,
) -> None: ) -> None:
"""Initialize the Jewish calendar sensor.""" """Initialize the Jewish calendar sensor."""
self.entity_description = description self.entity_description = description
self._attr_name = f"{data['name']} {description.name}" self._attr_name = f"{DEFAULT_NAME} {description.name}"
self._attr_unique_id = f"{data['prefix']}_{description.key}" self._attr_unique_id = f'{data["prefix"]}_{description.key}'
self._location = data["location"] self._location = data["location"]
self._hebrew = data["language"] == "hebrew" self._hebrew = data["language"] == "hebrew"
self._candle_lighting_offset = data["candle_lighting_offset"] 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) daytime_date = HDate(today, diaspora=self._diaspora, hebrew=self._hebrew)
# The Jewish day starts after darkness (called "tzais") and finishes at # The Jewish day starts after darkness (called "tzais") and finishes at
# sunset ("shkia"). The time in between is a gray area (aka "Bein # sunset ("shkia"). The time in between is a gray area
# Hashmashot" - literally: "in between the sun and the moon"). # (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 # For some sensors, it is more interesting to consider the date to be
# tomorrow based on sunset ("shkia"), for others based on "tzais". # tomorrow based on sunset ("shkia"), for others based on "tzais".

View 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"
}
}
}
}
}

View File

@@ -3,7 +3,6 @@
from collections.abc import Callable, Coroutine from collections.abc import Callable, Coroutine
from datetime import timedelta from datetime import timedelta
import logging import logging
from typing import Any
from bleak.backends.device import BLEDevice from bleak.backends.device import BLEDevice
from lmcloud import LMCloud as LaMarzoccoClient from lmcloud import LMCloud as LaMarzoccoClient
@@ -132,11 +131,11 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
self.lm.initialized = True self.lm.initialized = True
async def _async_handle_request( async def _async_handle_request[**_P](
self, self,
func: Callable[..., Coroutine[None, None, None]], func: Callable[_P, Coroutine[None, None, None]],
*args: Any, *args: _P.args,
**kwargs: Any, **kwargs: _P.kwargs,
) -> None: ) -> None:
"""Handle a request to the API.""" """Handle a request to the API."""
try: try:

View File

@@ -7,14 +7,16 @@ import logging
import os import os
from pathlib import Path from pathlib import Path
import time import time
from typing import Any
import voluptuous as vol import voluptuous as vol
from homeassistant.components.frontend import DATA_PANELS from homeassistant.components.frontend import DATA_PANELS
from homeassistant.const import CONF_FILENAME from homeassistant.const import CONF_FILENAME
from homeassistant.core import callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import collection, storage 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 homeassistant.util.yaml import Secrets, load_yaml_dict
from .const import ( from .const import (
@@ -42,11 +44,13 @@ _LOGGER = logging.getLogger(__name__)
class LovelaceConfig(ABC): class LovelaceConfig(ABC):
"""Base class for Lovelace config.""" """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.""" """Initialize Lovelace config."""
self.hass = hass self.hass = hass
if config: if config:
self.config = {**config, CONF_URL_PATH: url_path} self.config: dict[str, Any] | None = {**config, CONF_URL_PATH: url_path}
else: else:
self.config = None self.config = None
@@ -65,7 +69,7 @@ class LovelaceConfig(ABC):
"""Return the config info.""" """Return the config info."""
@abstractmethod @abstractmethod
async def async_load(self, force): async def async_load(self, force: bool) -> dict[str, Any]:
"""Load config.""" """Load config."""
async def async_save(self, config): async def async_save(self, config):
@@ -77,7 +81,7 @@ class LovelaceConfig(ABC):
raise HomeAssistantError("Not supported") raise HomeAssistantError("Not supported")
@callback @callback
def _config_updated(self): def _config_updated(self) -> None:
"""Fire config updated event.""" """Fire config updated event."""
self.hass.bus.async_fire(EVENT_LOVELACE_UPDATED, {"url_path": self.url_path}) self.hass.bus.async_fire(EVENT_LOVELACE_UPDATED, {"url_path": self.url_path})
@@ -85,10 +89,10 @@ class LovelaceConfig(ABC):
class LovelaceStorage(LovelaceConfig): class LovelaceStorage(LovelaceConfig):
"""Class to handle Storage based Lovelace config.""" """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.""" """Initialize Lovelace config based on storage helper."""
if config is None: if config is None:
url_path = None url_path: str | None = None
storage_key = CONFIG_STORAGE_KEY_DEFAULT storage_key = CONFIG_STORAGE_KEY_DEFAULT
else: else:
url_path = config[CONF_URL_PATH] url_path = config[CONF_URL_PATH]
@@ -96,8 +100,11 @@ class LovelaceStorage(LovelaceConfig):
super().__init__(hass, url_path, config) super().__init__(hass, url_path, config)
self._store = storage.Store(hass, CONFIG_STORAGE_VERSION, storage_key) self._store = storage.Store[dict[str, Any]](
self._data = None hass, CONFIG_STORAGE_VERSION, storage_key
)
self._data: dict[str, Any] | None = None
self._json_config: json_fragment | None = None
@property @property
def mode(self) -> str: def mode(self) -> str:
@@ -106,27 +113,30 @@ class LovelaceStorage(LovelaceConfig):
async def async_get_info(self): async def async_get_info(self):
"""Return the Lovelace storage info.""" """Return the Lovelace storage info."""
if self._data is None: data = self._data or await self._load()
await self._load() if data["config"] is None:
if self._data["config"] is None:
return {"mode": "auto-gen"} 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: bool) -> dict[str, Any]:
async def async_load(self, force):
"""Load config.""" """Load config."""
if self.hass.config.recovery_mode: if self.hass.config.recovery_mode:
raise ConfigNotFound raise ConfigNotFound
if self._data is None: data = self._data or await self._load()
await self._load() if (config := data["config"]) is None:
if (config := self._data["config"]) is None:
raise ConfigNotFound raise ConfigNotFound
return config 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): async def async_save(self, config):
"""Save config.""" """Save config."""
if self.hass.config.recovery_mode: if self.hass.config.recovery_mode:
@@ -135,6 +145,7 @@ class LovelaceStorage(LovelaceConfig):
if self._data is None: if self._data is None:
await self._load() await self._load()
self._data["config"] = config self._data["config"] = config
self._json_config = None
self._config_updated() self._config_updated()
await self._store.async_save(self._data) await self._store.async_save(self._data)
@@ -145,25 +156,37 @@ class LovelaceStorage(LovelaceConfig):
await self._store.async_remove() await self._store.async_remove()
self._data = None self._data = None
self._json_config = None
self._config_updated() self._config_updated()
async def _load(self): async def _load(self) -> dict[str, Any]:
"""Load the config.""" """Load the config."""
data = await self._store.async_load() data = await self._store.async_load()
self._data = data if data else {"config": None} 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 LovelaceYAML(LovelaceConfig):
"""Class to handle YAML-based Lovelace config.""" """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.""" """Initialize the YAML config."""
super().__init__(hass, url_path, config) super().__init__(hass, url_path, config)
self.path = hass.config.path( self.path = hass.config.path(
config[CONF_FILENAME] if config else LOVELACE_CONFIG_FILE config[CONF_FILENAME] if config else LOVELACE_CONFIG_FILE
) )
self._cache = None self._cache: tuple[dict[str, Any], float, json_fragment] | None = None
@property @property
def mode(self) -> str: def mode(self) -> str:
@@ -182,23 +205,35 @@ class LovelaceYAML(LovelaceConfig):
return _config_info(self.mode, config) return _config_info(self.mode, config)
async def async_load(self, force): async def async_load(self, force: bool) -> dict[str, Any]:
"""Load config.""" """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 self._load_config, force
) )
if is_updated: if is_updated:
self._config_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.""" """Load the actual config."""
# Check for a cached version of the config # Check for a cached version of the config
if not force and self._cache is not None: 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) modtime = os.path.getmtime(self.path)
if config and last_update > modtime: if config and last_update > modtime:
return False, config return False, config, json
is_updated = self._cache is not None is_updated = self._cache is not None
@@ -209,8 +244,9 @@ class LovelaceYAML(LovelaceConfig):
except FileNotFoundError: except FileNotFoundError:
raise ConfigNotFound from None raise ConfigNotFound from None
self._cache = (config, time.time()) json = json_fragment(json_bytes(config))
return is_updated, config self._cache = (config, time.time(), json)
return is_updated, config, json
def _config_info(mode, config): def _config_info(mode, config):

View File

@@ -11,6 +11,7 @@ from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.json import json_fragment
from .const import CONF_URL_PATH, DOMAIN, ConfigNotFound from .const import CONF_URL_PATH, DOMAIN, ConfigNotFound
from .dashboard import LovelaceStorage from .dashboard import LovelaceStorage
@@ -86,9 +87,9 @@ async def websocket_lovelace_config(
connection: websocket_api.ActiveConnection, connection: websocket_api.ActiveConnection,
msg: dict[str, Any], msg: dict[str, Any],
config: LovelaceStorage, config: LovelaceStorage,
) -> None: ) -> json_fragment:
"""Send Lovelace UI config over WebSocket configuration.""" """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 @websocket_api.require_admin
@@ -137,7 +138,7 @@ def websocket_lovelace_dashboards(
connection: websocket_api.ActiveConnection, connection: websocket_api.ActiveConnection,
msg: dict[str, Any], msg: dict[str, Any],
) -> None: ) -> None:
"""Delete Lovelace UI configuration.""" """Send Lovelace dashboard configuration."""
connection.send_result( connection.send_result(
msg["id"], msg["id"],
[ [

View File

@@ -52,7 +52,10 @@ DEFAULT_TRANSITION = 0.2
# sw version (attributeKey 0/40/10) # sw version (attributeKey 0/40/10)
TRANSITION_BLOCKLIST = ( TRANSITION_BLOCKLIST = (
(4488, 514, "1.0", "1.0.0"), (4488, 514, "1.0", "1.0.0"),
(4488, 260, "1.0", "1.0.0"),
(5010, 769, "3.0", "1.0.0"), (5010, 769, "3.0", "1.0.0"),
(4999, 25057, "1.0", "27.0"),
(4448, 36866, "V1", "V1.0.0.5"),
) )

View File

@@ -243,7 +243,7 @@ class MikrotikData:
return [] return []
class MikrotikDataUpdateCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-enforce-coordinator-module class MikrotikDataUpdateCoordinator(DataUpdateCoordinator[None]):
"""Mikrotik Hub Object.""" """Mikrotik Hub Object."""
def __init__( def __init__(

View File

@@ -2,26 +2,17 @@
from __future__ import annotations from __future__ import annotations
from datetime import timedelta
import logging
import aiohttp
from moehlenhoff_alpha2 import Alpha2Base from moehlenhoff_alpha2 import Alpha2Base
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN from .const import DOMAIN
from .coordinator import Alpha2BaseCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR] 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: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry.""" """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: async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update.""" """Handle options update."""
await hass.config_entries.async_reload(entry.entry_id) 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()

View File

@@ -10,8 +10,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import Alpha2BaseCoordinator
from .const import DOMAIN from .const import DOMAIN
from .coordinator import Alpha2BaseCoordinator
async def async_setup_entry( async def async_setup_entry(

View File

@@ -8,8 +8,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from . import Alpha2BaseCoordinator
from .const import DOMAIN from .const import DOMAIN
from .coordinator import Alpha2BaseCoordinator
async def async_setup_entry( async def async_setup_entry(

View File

@@ -15,8 +15,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import Alpha2BaseCoordinator
from .const import DOMAIN, PRESET_AUTO, PRESET_DAY, PRESET_NIGHT from .const import DOMAIN, PRESET_AUTO, PRESET_DAY, PRESET_NIGHT
from .coordinator import Alpha2BaseCoordinator
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)

View 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()

View File

@@ -7,8 +7,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import Alpha2BaseCoordinator
from .const import DOMAIN from .const import DOMAIN
from .coordinator import Alpha2BaseCoordinator
async def async_setup_entry( async def async_setup_entry(

View File

@@ -414,7 +414,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def handle_webhook( async def handle_webhook(
hass: HomeAssistant, webhook_id: str, request: Request hass: HomeAssistant, webhook_id: str, request: Request
) -> None | Response: ) -> Response | None:
"""Handle webhook callback.""" """Handle webhook callback."""
try: try:

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