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

View File

@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.4
rev: v0.4.5
hooks:
- id: ruff
args:
@@ -8,11 +8,11 @@ repos:
- id: ruff-format
files: ^((homeassistant|pylint|script|tests)/.+)?[^/]+\.(py|pyi)$
- repo: https://github.com/codespell-project/codespell
rev: v2.2.6
rev: v2.3.0
hooks:
- id: codespell
args:
- --ignore-words-list=additionals,alle,alot,astroid,bund,caf,convencional,currenty,datas,farenheit,falsy,fo,frequence,haa,hass,iif,incomfort,ines,ist,nam,nd,pres,pullrequests,resset,rime,ser,serie,te,technik,ue,unsecure,vor,withing,zar
- --ignore-words-list=astroid,checkin,currenty,hass,iif,incomfort,lookin,nam,NotIn,pres,ser,ue
- --skip="./.*,*.csv,*.json,*.ambr"
- --quiet-level=2
exclude_types: [csv, json, html]

View File

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

View File

@@ -5,7 +5,7 @@
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
identity and expression, level of experience, education, socioeconomic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.

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

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()
hass.config_entries = config_entries.ConfigEntries(hass, config)
# Prime custom component cache early so we know if registry entries are tied
# to a custom integration
await loader.async_get_custom_components(hass)
await async_load_base_functionality(hass)
# Set up core.

View File

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

View File

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

View File

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

View File

@@ -162,7 +162,6 @@ from homeassistant.util import dt as dt_util
from . import indieauth, login_flow, mfa_setup_flow
DOMAIN = "auth"
STRICT_CONNECTION_URL = "/auth/strict_connection/temp_token"
type StoreResultType = Callable[[str, Credentials], str]
type RetrieveResultType = Callable[[str, str], Credentials | None]
@@ -188,7 +187,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.http.register_view(RevokeTokenView())
hass.http.register_view(LinkUserView(retrieve_result))
hass.http.register_view(OAuth2AuthorizeCallbackView())
hass.http.register_view(StrictConnectionTempTokenView())
websocket_api.async_register_command(hass, websocket_current_user)
websocket_api.async_register_command(hass, websocket_create_long_lived_access_token)
@@ -323,7 +321,6 @@ class TokenView(HomeAssistantView):
status_code=HTTPStatus.FORBIDDEN,
)
await hass.auth.session.async_create_session(request, refresh_token)
return self.json(
{
"access_token": access_token,
@@ -392,7 +389,6 @@ class TokenView(HomeAssistantView):
status_code=HTTPStatus.FORBIDDEN,
)
await hass.auth.session.async_create_session(request, refresh_token)
return self.json(
{
"access_token": access_token,
@@ -441,20 +437,6 @@ class LinkUserView(HomeAssistantView):
return self.json_message("User linked")
class StrictConnectionTempTokenView(HomeAssistantView):
"""View to get temporary strict connection token."""
url = STRICT_CONNECTION_URL
name = "api:auth:strict_connection:temp_token"
requires_auth = False
async def get(self, request: web.Request) -> web.Response:
"""Get a temporary token and redirect to main page."""
hass = request.app[KEY_HASS]
await hass.auth.session.async_create_temp_unauthorized_session(request)
raise web.HTTPSeeOther(location="/")
@callback
def _create_auth_code_store() -> tuple[StoreResultType, RetrieveResultType]:
"""Create an in memory store."""

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-data-tools==1.19.0",
"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"
)
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 enum import Enum
from typing import cast
from urllib.parse import quote_plus, urljoin
from hass_nabucasa import Cloud
import voluptuous as vol
from homeassistant.components import alexa, google_assistant, http
from homeassistant.components.auth import STRICT_CONNECTION_URL
from homeassistant.components.http.auth import async_sign_path
from homeassistant.components import alexa, google_assistant
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
from homeassistant.const import (
CONF_DESCRIPTION,
@@ -24,21 +21,8 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
Platform,
)
from homeassistant.core import (
Event,
HassJob,
HomeAssistant,
ServiceCall,
ServiceResponse,
SupportsResponse,
callback,
)
from homeassistant.exceptions import (
HomeAssistantError,
ServiceValidationError,
Unauthorized,
UnknownUser,
)
from homeassistant.core import Event, HassJob, HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entityfilter
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.discovery import async_load_platform
@@ -47,7 +31,6 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_send,
)
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.network import NoURLAvailableError, get_url
from homeassistant.helpers.service import async_register_admin_service
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
@@ -418,50 +401,3 @@ def _setup_services(hass: HomeAssistant, prefs: CloudPreferences) -> None:
async_register_admin_service(
hass, DOMAIN, SERVICE_REMOTE_DISCONNECT, _service_handler
)
async def create_temporary_strict_connection_url(
call: ServiceCall,
) -> ServiceResponse:
"""Create a strict connection url and return it."""
# Copied form homeassistant/helpers/service.py#_async_admin_handler
# as the helper supports no responses yet
if call.context.user_id:
user = await hass.auth.async_get_user(call.context.user_id)
if user is None:
raise UnknownUser(context=call.context)
if not user.is_admin:
raise Unauthorized(context=call.context)
if prefs.strict_connection is http.const.StrictConnectionMode.DISABLED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="strict_connection_not_enabled",
)
try:
url = get_url(hass, require_cloud=True)
except NoURLAvailableError as ex:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="no_url_available",
) from ex
path = async_sign_path(
hass,
STRICT_CONNECTION_URL,
timedelta(hours=1),
use_content_user=True,
)
url = urljoin(url, path)
return {
"url": f"https://login.home-assistant.io?u={quote_plus(url)}",
"direct_url": url,
}
hass.services.async_register(
DOMAIN,
"create_temporary_strict_connection_url",
create_temporary_strict_connection_url,
supports_response=SupportsResponse.ONLY,
)

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
{
"services": {
"create_temporary_strict_connection_url": "mdi:login-variant",
"remote_connect": "mdi:cloud",
"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.models import User
from homeassistant.components import http, webhook
from homeassistant.components import webhook
from homeassistant.components.google_assistant.http import (
async_get_users as async_get_google_assistant_users,
)
@@ -44,7 +44,6 @@ from .const import (
PREF_INSTANCE_ID,
PREF_REMOTE_ALLOW_REMOTE_ENABLE,
PREF_REMOTE_DOMAIN,
PREF_STRICT_CONNECTION,
PREF_TTS_DEFAULT_VOICE,
PREF_USERNAME,
)
@@ -177,7 +176,6 @@ class CloudPreferences:
google_settings_version: int | UndefinedType = UNDEFINED,
google_connected: bool | UndefinedType = UNDEFINED,
remote_allow_remote_enable: bool | UndefinedType = UNDEFINED,
strict_connection: http.const.StrictConnectionMode | UndefinedType = UNDEFINED,
) -> None:
"""Update user preferences."""
prefs = {**self._prefs}
@@ -197,7 +195,6 @@ class CloudPreferences:
(PREF_REMOTE_DOMAIN, remote_domain),
(PREF_GOOGLE_CONNECTED, google_connected),
(PREF_REMOTE_ALLOW_REMOTE_ENABLE, remote_allow_remote_enable),
(PREF_STRICT_CONNECTION, strict_connection),
):
if value is not UNDEFINED:
prefs[key] = value
@@ -245,7 +242,6 @@ class CloudPreferences:
PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin,
PREF_REMOTE_ALLOW_REMOTE_ENABLE: self.remote_allow_remote_enable,
PREF_TTS_DEFAULT_VOICE: self.tts_default_voice,
PREF_STRICT_CONNECTION: self.strict_connection,
}
@property
@@ -362,20 +358,6 @@ class CloudPreferences:
"""
return self._prefs.get(PREF_TTS_DEFAULT_VOICE, DEFAULT_TTS_DEFAULT_VOICE) # type: ignore[no-any-return]
@property
def strict_connection(self) -> http.const.StrictConnectionMode:
"""Return the strict connection mode."""
mode = self._prefs.get(PREF_STRICT_CONNECTION)
if mode is None:
# Set to default value
# We store None in the store as the default value to detect if the user has changed the
# value or not.
mode = http.const.StrictConnectionMode.DISABLED
elif not isinstance(mode, http.const.StrictConnectionMode):
mode = http.const.StrictConnectionMode(mode)
return mode
async def get_cloud_user(self) -> str:
"""Return ID of Home Assistant Cloud system user."""
user = await self._load_cloud_user()
@@ -433,5 +415,4 @@ class CloudPreferences:
PREF_REMOTE_DOMAIN: None,
PREF_REMOTE_ALLOW_REMOTE_ENABLE: True,
PREF_USERNAME: username,
PREF_STRICT_CONNECTION: None,
}

View File

@@ -5,14 +5,6 @@
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
}
},
"exceptions": {
"strict_connection_not_enabled": {
"message": "Strict connection is not enabled for cloud requests"
},
"no_url_available": {
"message": "No cloud URL available.\nPlease mark sure you have a working Remote UI."
}
},
"system_health": {
"info": {
"can_reach_cert_server": "Reach Certificate Server",
@@ -81,10 +73,6 @@
}
},
"services": {
"create_temporary_strict_connection_url": {
"name": "Create a temporary strict connection URL",
"description": "Create a temporary strict connection URL, which can be used to login on another device."
},
"remote_connect": {
"name": "Remote connect",
"description": "Makes the instance UI accessible from outside of the local network by using Home Assistant Cloud."

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",
"YER": "YER",
"YFI": "YFI",
"ZAR": "ZAR",
"ZAR": "ZAR", # codespell:ignore zar
"ZEC": "ZEC",
"ZMW": "ZMW",
"ZRX": "ZRX",
@@ -550,7 +550,7 @@ RATES = {
"TRAC": "TRAC",
"TRB": "TRB",
"TRIBE": "TRIBE",
"TRU": "TRU",
"TRU": "TRU", # codespell:ignore tru
"TRY": "TRY",
"TTD": "TTD",
"TWD": "TWD",
@@ -590,7 +590,7 @@ RATES = {
"YER": "YER",
"YFI": "YFI",
"YFII": "YFII",
"ZAR": "ZAR",
"ZAR": "ZAR", # codespell:ignore zar
"ZEC": "ZEC",
"ZEN": "ZEN",
"ZMW": "ZMW",

View File

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

View File

@@ -51,6 +51,11 @@
"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},
}
AQARA_MINI_SWITCH_MODEL = "lumi.remote.b1acn01"
AQARA_MINI_SWITCH_WXKG11LM_MODEL = "lumi.remote.b1acn01"
AQARA_MINI_SWITCH_WBR02D_MODEL = "lumi.remote.b1acn02"
AQARA_MINI_SWITCH = {
(CONF_SHORT_PRESS, CONF_TURN_ON): {CONF_EVENT: 1002},
(CONF_DOUBLE_PRESS, CONF_TURN_ON): {CONF_EVENT: 1004},
@@ -615,7 +616,8 @@ REMOTES = {
AQARA_SINGLE_WALL_SWITCH_QBKG11LM_MODEL: AQARA_SINGLE_WALL_SWITCH_QBKG11LM,
AQARA_SINGLE_WALL_SWITCH_WXKG03LM_MODEL: AQARA_SINGLE_WALL_SWITCH,
AQARA_SINGLE_WALL_SWITCH_WXKG06LM_MODEL: AQARA_SINGLE_WALL_SWITCH,
AQARA_MINI_SWITCH_MODEL: AQARA_MINI_SWITCH,
AQARA_MINI_SWITCH_WXKG11LM_MODEL: AQARA_MINI_SWITCH,
AQARA_MINI_SWITCH_WBR02D_MODEL: AQARA_MINI_SWITCH,
AQARA_ROUND_SWITCH_MODEL: AQARA_ROUND_SWITCH,
AQARA_SQUARE_SWITCH_MODEL: AQARA_SQUARE_SWITCH,
AQARA_SQUARE_SWITCH_WXKG11LM_2016_MODEL: AQARA_SQUARE_SWITCH_WXKG11LM_2016,

View File

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

View File

@@ -3,9 +3,9 @@
Data is fetched from DWD:
https://rcccm.dwd.de/DE/wetter/warnungen_aktuell/objekt_einbindung/objekteinbindung.html
Warnungen vor extremem Unwetter (Stufe 4)
Warnungen vor extremem Unwetter (Stufe 4) # codespell:ignore vor
Unwetterwarnungen (Stufe 3)
Warnungen vor markantem Wetter (Stufe 2)
Warnungen vor markantem Wetter (Stufe 2) # codespell:ignore vor
Wetterwarnungen (Stufe 1)
"""

View File

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

View File

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

View File

@@ -58,6 +58,12 @@
"reset_lifespan_lens_brush": {
"name": "Reset lens brush lifespan"
},
"reset_lifespan_round_mop": {
"name": "Reset round mop lifespan"
},
"reset_lifespan_unit_care": {
"name": "Reset unit care lifespan"
},
"reset_lifespan_side_brush": {
"name": "Reset side brushes lifespan"
}
@@ -113,6 +119,12 @@
"lifespan_side_brush": {
"name": "Side brushes lifespan"
},
"lifespan_unit_care": {
"name": "Unit care lifespan"
},
"lifespan_round_mop": {
"name": "Round mop lifespan"
},
"network_ip": {
"name": "IP address"
},

View File

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

View File

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

View File

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

View File

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

View File

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

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
LANG_TO_BROADCAST_COMMAND = {
"en": ("broadcast {0}", "broadcast to {1} {0}"),
"de": ("Nachricht an alle {0}", "Nachricht an alle an {1} {0}"),
"de": (
"Nachricht an alle {0}", # codespell:ignore alle
"Nachricht an alle an {1} {0}", # codespell:ignore alle
),
"es": ("Anuncia {0}", "Anuncia en {1} {0}"),
"fr": ("Diffuse {0}", "Diffuse dans {1} {0}"),
"it": ("Trasmetti {0}", "Trasmetti in {1} {0}"),

View File

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

View File

@@ -32,18 +32,24 @@ from homeassistant.helpers.selector import (
from .const import (
CONF_CHAT_MODEL,
CONF_DANGEROUS_BLOCK_THRESHOLD,
CONF_HARASSMENT_BLOCK_THRESHOLD,
CONF_HATE_BLOCK_THRESHOLD,
CONF_MAX_TOKENS,
CONF_PROMPT,
CONF_RECOMMENDED,
CONF_SEXUAL_BLOCK_THRESHOLD,
CONF_TEMPERATURE,
CONF_TOP_K,
CONF_TOP_P,
DEFAULT_CHAT_MODEL,
DEFAULT_MAX_TOKENS,
DEFAULT_PROMPT,
DEFAULT_TEMPERATURE,
DEFAULT_TOP_K,
DEFAULT_TOP_P,
DOMAIN,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_HARM_BLOCK_THRESHOLD,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_K,
RECOMMENDED_TOP_P,
)
_LOGGER = logging.getLogger(__name__)
@@ -54,6 +60,12 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
}
)
RECOMMENDED_OPTIONS = {
CONF_RECOMMENDED: True,
CONF_LLM_HASS_API: llm.LLM_API_ASSIST,
CONF_PROMPT: DEFAULT_PROMPT,
}
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
"""Validate the user input allows us to connect.
@@ -94,7 +106,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(
title="Google Generative AI",
data=user_input,
options={CONF_LLM_HASS_API: llm.LLM_API_ASSIST},
options=RECOMMENDED_OPTIONS,
)
return self.async_show_form(
@@ -115,18 +127,32 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow):
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize options flow."""
self.config_entry = config_entry
self.last_rendered_recommended = config_entry.options.get(
CONF_RECOMMENDED, False
)
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the options."""
options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options
if user_input is not None:
if user_input[CONF_LLM_HASS_API] == "none":
user_input.pop(CONF_LLM_HASS_API)
return self.async_create_entry(title="", data=user_input)
schema = await google_generative_ai_config_option_schema(
self.hass, self.config_entry.options
)
if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended:
if user_input[CONF_LLM_HASS_API] == "none":
user_input.pop(CONF_LLM_HASS_API)
return self.async_create_entry(title="", data=user_input)
# Re-render the options again, now with the recommended options shown/hidden
self.last_rendered_recommended = user_input[CONF_RECOMMENDED]
options = {
CONF_RECOMMENDED: user_input[CONF_RECOMMENDED],
CONF_PROMPT: user_input[CONF_PROMPT],
CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API],
}
schema = await google_generative_ai_config_option_schema(self.hass, options)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(schema),
@@ -135,41 +161,16 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow):
async def google_generative_ai_config_option_schema(
hass: HomeAssistant,
options: MappingProxyType[str, Any],
options: dict[str, Any] | MappingProxyType[str, Any],
) -> dict:
"""Return a schema for Google Generative AI completion options."""
api_models = await hass.async_add_executor_job(partial(genai.list_models))
models: list[SelectOptionDict] = [
SelectOptionDict(
label="Gemini 1.5 Flash (recommended)",
value="models/gemini-1.5-flash-latest",
),
]
models.extend(
SelectOptionDict(
label=api_model.display_name,
value=api_model.name,
)
for api_model in sorted(api_models, key=lambda x: x.display_name)
if (
api_model.name
not in (
"models/gemini-1.0-pro", # duplicate of gemini-pro
"models/gemini-1.5-flash-latest",
)
and "vision" not in api_model.name
and "generateContent" in api_model.supported_generation_methods
)
)
apis: list[SelectOptionDict] = [
hass_apis: list[SelectOptionDict] = [
SelectOptionDict(
label="No control",
value="none",
)
]
apis.extend(
hass_apis.extend(
SelectOptionDict(
label=api.name,
value=api.id,
@@ -177,45 +178,119 @@ async def google_generative_ai_config_option_schema(
for api in llm.async_get_apis(hass)
)
return {
vol.Optional(
CONF_CHAT_MODEL,
description={"suggested_value": options.get(CONF_CHAT_MODEL)},
default=DEFAULT_CHAT_MODEL,
): SelectSelector(
SelectSelectorConfig(
mode=SelectSelectorMode.DROPDOWN,
options=models,
)
),
vol.Optional(
CONF_LLM_HASS_API,
description={"suggested_value": options.get(CONF_LLM_HASS_API)},
default="none",
): SelectSelector(SelectSelectorConfig(options=apis)),
schema = {
vol.Optional(
CONF_PROMPT,
description={"suggested_value": options.get(CONF_PROMPT)},
default=DEFAULT_PROMPT,
): TemplateSelector(),
vol.Optional(
CONF_TEMPERATURE,
description={"suggested_value": options.get(CONF_TEMPERATURE)},
default=DEFAULT_TEMPERATURE,
): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
vol.Optional(
CONF_TOP_P,
description={"suggested_value": options.get(CONF_TOP_P)},
default=DEFAULT_TOP_P,
): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
vol.Optional(
CONF_TOP_K,
description={"suggested_value": options.get(CONF_TOP_K)},
default=DEFAULT_TOP_K,
): int,
vol.Optional(
CONF_MAX_TOKENS,
description={"suggested_value": options.get(CONF_MAX_TOKENS)},
default=DEFAULT_MAX_TOKENS,
): int,
CONF_LLM_HASS_API,
description={"suggested_value": options.get(CONF_LLM_HASS_API)},
default="none",
): SelectSelector(SelectSelectorConfig(options=hass_apis)),
vol.Required(
CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)
): bool,
}
if options.get(CONF_RECOMMENDED):
return schema
api_models = await hass.async_add_executor_job(partial(genai.list_models))
models = [
SelectOptionDict(
label=api_model.display_name,
value=api_model.name,
)
for api_model in sorted(api_models, key=lambda x: x.display_name)
if (
api_model.name != "models/gemini-1.0-pro" # duplicate of gemini-pro
and "vision" not in api_model.name
and "generateContent" in api_model.supported_generation_methods
)
]
harm_block_thresholds: list[SelectOptionDict] = [
SelectOptionDict(
label="Block none",
value="BLOCK_NONE",
),
SelectOptionDict(
label="Block few",
value="BLOCK_ONLY_HIGH",
),
SelectOptionDict(
label="Block some",
value="BLOCK_MEDIUM_AND_ABOVE",
),
SelectOptionDict(
label="Block most",
value="BLOCK_LOW_AND_ABOVE",
),
]
harm_block_thresholds_selector = SelectSelector(
SelectSelectorConfig(
mode=SelectSelectorMode.DROPDOWN, options=harm_block_thresholds
)
)
schema.update(
{
vol.Optional(
CONF_CHAT_MODEL,
description={"suggested_value": options.get(CONF_CHAT_MODEL)},
default=RECOMMENDED_CHAT_MODEL,
): SelectSelector(
SelectSelectorConfig(mode=SelectSelectorMode.DROPDOWN, options=models)
),
vol.Optional(
CONF_TEMPERATURE,
description={"suggested_value": options.get(CONF_TEMPERATURE)},
default=RECOMMENDED_TEMPERATURE,
): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
vol.Optional(
CONF_TOP_P,
description={"suggested_value": options.get(CONF_TOP_P)},
default=RECOMMENDED_TOP_P,
): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
vol.Optional(
CONF_TOP_K,
description={"suggested_value": options.get(CONF_TOP_K)},
default=RECOMMENDED_TOP_K,
): int,
vol.Optional(
CONF_MAX_TOKENS,
description={"suggested_value": options.get(CONF_MAX_TOKENS)},
default=RECOMMENDED_MAX_TOKENS,
): int,
vol.Optional(
CONF_HARASSMENT_BLOCK_THRESHOLD,
description={
"suggested_value": options.get(CONF_HARASSMENT_BLOCK_THRESHOLD)
},
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
): harm_block_thresholds_selector,
vol.Optional(
CONF_HATE_BLOCK_THRESHOLD,
description={"suggested_value": options.get(CONF_HATE_BLOCK_THRESHOLD)},
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
): harm_block_thresholds_selector,
vol.Optional(
CONF_SEXUAL_BLOCK_THRESHOLD,
description={
"suggested_value": options.get(CONF_SEXUAL_BLOCK_THRESHOLD)
},
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
): harm_block_thresholds_selector,
vol.Optional(
CONF_DANGEROUS_BLOCK_THRESHOLD,
description={
"suggested_value": options.get(CONF_DANGEROUS_BLOCK_THRESHOLD)
},
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
): harm_block_thresholds_selector,
}
)
return schema

View File

@@ -5,32 +5,21 @@ import logging
DOMAIN = "google_generative_ai_conversation"
LOGGER = logging.getLogger(__package__)
CONF_PROMPT = "prompt"
DEFAULT_PROMPT = """This smart home is controlled by Home Assistant.
An overview of the areas and the devices in this smart home:
{%- for area in areas() %}
{%- set area_info = namespace(printed=false) %}
{%- for device in area_devices(area) -%}
{%- if not device_attr(device, "disabled_by") and not device_attr(device, "entry_type") and device_attr(device, "name") %}
{%- if not area_info.printed %}
{{ area_name(area) }}:
{%- set area_info.printed = true %}
{%- endif %}
- {{ device_attr(device, "name") }}{% if device_attr(device, "model") and (device_attr(device, "model") | string) not in (device_attr(device, "name") | string) %} ({{ device_attr(device, "model") }}){% endif %}
{%- endif %}
{%- endfor %}
{%- endfor %}
"""
DEFAULT_PROMPT = "Answer in plain text. Keep it simple and to the point."
CONF_RECOMMENDED = "recommended"
CONF_CHAT_MODEL = "chat_model"
DEFAULT_CHAT_MODEL = "models/gemini-pro"
RECOMMENDED_CHAT_MODEL = "models/gemini-1.5-flash-latest"
CONF_TEMPERATURE = "temperature"
DEFAULT_TEMPERATURE = 0.9
RECOMMENDED_TEMPERATURE = 1.0
CONF_TOP_P = "top_p"
DEFAULT_TOP_P = 1.0
RECOMMENDED_TOP_P = 0.95
CONF_TOP_K = "top_k"
DEFAULT_TOP_K = 1
RECOMMENDED_TOP_K = 64
CONF_MAX_TOKENS = "max_tokens"
DEFAULT_MAX_TOKENS = 150
DEFAULT_ALLOW_HASS_ACCESS = False
RECOMMENDED_MAX_TOKENS = 150
CONF_HARASSMENT_BLOCK_THRESHOLD = "harassment_block_threshold"
CONF_HATE_BLOCK_THRESHOLD = "hate_block_threshold"
CONF_SEXUAL_BLOCK_THRESHOLD = "sexual_block_threshold"
CONF_DANGEROUS_BLOCK_THRESHOLD = "dangerous_block_threshold"
RECOMMENDED_HARM_BLOCK_THRESHOLD = "BLOCK_LOW_AND_ABOVE"

View File

@@ -16,25 +16,30 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, TemplateError
from homeassistant.helpers import intent, llm, template
from homeassistant.helpers import device_registry as dr, intent, llm, template
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import ulid
from .const import (
CONF_CHAT_MODEL,
CONF_DANGEROUS_BLOCK_THRESHOLD,
CONF_HARASSMENT_BLOCK_THRESHOLD,
CONF_HATE_BLOCK_THRESHOLD,
CONF_MAX_TOKENS,
CONF_PROMPT,
CONF_SEXUAL_BLOCK_THRESHOLD,
CONF_TEMPERATURE,
CONF_TOP_K,
CONF_TOP_P,
DEFAULT_CHAT_MODEL,
DEFAULT_MAX_TOKENS,
DEFAULT_PROMPT,
DEFAULT_TEMPERATURE,
DEFAULT_TOP_K,
DEFAULT_TOP_P,
DOMAIN,
LOGGER,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_HARM_BLOCK_THRESHOLD,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_K,
RECOMMENDED_TOP_P,
)
# Max number of back and forth with the LLM to generate a response
@@ -106,13 +111,20 @@ class GoogleGenerativeAIConversationEntity(
"""Google Generative AI conversation agent."""
_attr_has_entity_name = True
_attr_name = None
def __init__(self, entry: ConfigEntry) -> None:
"""Initialize the agent."""
self.entry = entry
self.history: dict[str, list[genai_types.ContentType]] = {}
self._attr_name = entry.title
self._attr_unique_id = entry.entry_id
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name=entry.title,
manufacturer="Google",
model="Generative AI",
entry_type=dr.DeviceEntryType.SERVICE,
)
@property
def supported_languages(self) -> list[str] | Literal["*"]:
@@ -156,17 +168,30 @@ class GoogleGenerativeAIConversationEntity(
)
tools = [_format_tool(tool) for tool in llm_api.async_get_tools()]
raw_prompt = self.entry.options.get(CONF_PROMPT, DEFAULT_PROMPT)
model = genai.GenerativeModel(
model_name=self.entry.options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL),
model_name=self.entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
generation_config={
"temperature": self.entry.options.get(
CONF_TEMPERATURE, DEFAULT_TEMPERATURE
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
),
"top_p": self.entry.options.get(CONF_TOP_P, DEFAULT_TOP_P),
"top_k": self.entry.options.get(CONF_TOP_K, DEFAULT_TOP_K),
"top_p": self.entry.options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
"top_k": self.entry.options.get(CONF_TOP_K, RECOMMENDED_TOP_K),
"max_output_tokens": self.entry.options.get(
CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS
CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS
),
},
safety_settings={
"HARASSMENT": self.entry.options.get(
CONF_HARASSMENT_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
),
"HATE": self.entry.options.get(
CONF_HATE_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
),
"SEXUAL": self.entry.options.get(
CONF_SEXUAL_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
),
"DANGEROUS": self.entry.options.get(
CONF_DANGEROUS_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
),
},
tools=tools or None,
@@ -180,7 +205,31 @@ class GoogleGenerativeAIConversationEntity(
messages = [{}, {}]
try:
prompt = self._async_generate_prompt(raw_prompt, llm_api)
prompt = template.Template(
self.entry.options.get(CONF_PROMPT, DEFAULT_PROMPT), self.hass
).async_render(
{
"ha_name": self.hass.config.location_name,
},
parse_result=False,
)
if llm_api:
empty_tool_input = llm.ToolInput(
tool_name="",
tool_args={},
platform=DOMAIN,
context=user_input.context,
user_prompt=user_input.text,
language=user_input.language,
assistant=conversation.DOMAIN,
device_id=user_input.device_id,
)
prompt = (
await llm_api.async_get_api_prompt(empty_tool_input) + "\n" + prompt
)
except TemplateError as err:
LOGGER.error("Error rendering prompt: %s", err)
intent_response.async_set_error(
@@ -221,7 +270,7 @@ class GoogleGenerativeAIConversationEntity(
if not chat_response.parts:
intent_response.async_set_error(
intent.IntentResponseErrorCode.UNKNOWN,
"Sorry, I had a problem talking to Google Generative AI. Likely blocked",
"Sorry, I had a problem getting a response from Google Generative AI.",
)
return conversation.ConversationResult(
response=intent_response, conversation_id=conversation_id
@@ -267,18 +316,3 @@ class GoogleGenerativeAIConversationEntity(
return conversation.ConversationResult(
response=intent_response, conversation_id=conversation_id
)
def _async_generate_prompt(self, raw_prompt: str, llm_api: llm.API | None) -> str:
"""Generate a prompt for the user."""
raw_prompt += "\n"
if llm_api:
raw_prompt += llm_api.prompt_template
else:
raw_prompt += llm.PROMPT_NO_API_CONFIGURED
return template.Template(raw_prompt, self.hass).async_render(
{
"ha_name": self.hass.config.location_name,
},
parse_result=False,
)

View File

@@ -18,13 +18,21 @@
"step": {
"init": {
"data": {
"prompt": "Prompt Template",
"recommended": "Recommended model settings",
"prompt": "Instructions",
"chat_model": "[%key:common::generic::model%]",
"temperature": "Temperature",
"top_p": "Top P",
"top_k": "Top K",
"max_tokens": "Maximum tokens to return in response",
"llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]"
"llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]",
"harassment_block_threshold": "Negative or harmful comments targeting identity and/or protected attributes",
"hate_block_threshold": "Content that is rude, disrespectful, or profane",
"sexual_block_threshold": "Contains references to sexual acts or other lewd content",
"dangerous_block_threshold": "Promotes, facilitates, or encourages harmful acts"
},
"data_description": {
"prompt": "Instruct how the LLM should respond. This can be a template."
}
}
}

View File

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

View File

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

View File

@@ -3,6 +3,11 @@
from __future__ import annotations
import asyncio
from contextlib import suppress
from errno import EADDRINUSE
import logging
from govee_local_api.controller import LISTENING_PORT
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
@@ -14,14 +19,29 @@ from .coordinator import GoveeLocalApiCoordinator
PLATFORMS: list[Platform] = [Platform.LIGHT]
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Govee light local from a config entry."""
coordinator: GoveeLocalApiCoordinator = GoveeLocalApiCoordinator(hass=hass)
entry.async_on_unload(coordinator.cleanup)
await coordinator.start()
async def await_cleanup():
cleanup_complete: asyncio.Event = coordinator.cleanup()
with suppress(TimeoutError):
await asyncio.wait_for(cleanup_complete.wait(), 1)
entry.async_on_unload(await_cleanup)
try:
await coordinator.start()
except OSError as ex:
if ex.errno != EADDRINUSE:
_LOGGER.error("Start failed, errno: %d", ex.errno)
return False
_LOGGER.error("Port %s already in use", LISTENING_PORT)
raise ConfigEntryNotReady from ex
await coordinator.async_config_entry_first_refresh()

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
from contextlib import suppress
import logging
from govee_local_api import GoveeController
@@ -39,7 +40,11 @@ async def _async_has_devices(hass: HomeAssistant) -> bool:
update_enabled=False,
)
await controller.start()
try:
await controller.start()
except OSError as ex:
_LOGGER.error("Start failed, errno: %d", ex.errno)
return False
try:
async with asyncio.timeout(delay=DISCOVERY_TIMEOUT):
@@ -49,7 +54,9 @@ async def _async_has_devices(hass: HomeAssistant) -> bool:
_LOGGER.debug("No devices found")
devices_count = len(controller.devices)
controller.cleanup()
cleanup_complete: asyncio.Event = controller.cleanup()
with suppress(TimeoutError):
await asyncio.wait_for(cleanup_complete.wait(), 1)
return devices_count > 0

View File

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

View File

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

View File

@@ -34,7 +34,7 @@ class GuardianDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
entry: ConfigEntry,
client: Client,
api_name: str,
api_coro: Callable[..., Coroutine[Any, Any, dict[str, Any]]],
api_coro: Callable[[], Coroutine[Any, Any, dict[str, Any]]],
api_lock: asyncio.Lock,
valve_controller_uid: str,
) -> None:

View File

@@ -275,7 +275,7 @@ def async_remove_addons_from_dev_reg(
dev_reg.async_remove_device(dev.id)
class HassioDataUpdateCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-coordinator-module
class HassioDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to retrieve Hass.io status."""
def __init__(

View File

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

View File

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

View File

@@ -72,6 +72,8 @@ from .const import (
CONF_STREAM_COUNT,
CONF_STREAM_SOURCE,
CONF_SUPPORT_AUDIO,
CONF_THRESHOLD_CO,
CONF_THRESHOLD_CO2,
CONF_VIDEO_CODEC,
CONF_VIDEO_MAP,
CONF_VIDEO_PACKET_SIZE,
@@ -223,6 +225,13 @@ SWITCH_TYPE_SCHEMA = BASIC_INFO_SCHEMA.extend(
}
)
SENSOR_SCHEMA = BASIC_INFO_SCHEMA.extend(
{
vol.Optional(CONF_THRESHOLD_CO): vol.Any(None, cv.positive_int),
vol.Optional(CONF_THRESHOLD_CO2): vol.Any(None, cv.positive_int),
}
)
HOMEKIT_CHAR_TRANSLATIONS = {
0: " ", # nul
@@ -297,6 +306,9 @@ def validate_entity_config(values: dict) -> dict[str, dict]:
elif domain == "cover":
config = COVER_SCHEMA(config)
elif domain == "sensor":
config = SENSOR_SCHEMA(config)
else:
config = BASIC_INFO_SCHEMA(config)

View File

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

View File

@@ -110,7 +110,7 @@ class HKDevice:
# A list of callbacks that turn HK characteristics into entities
self.char_factories: list[AddCharacteristicCb] = []
# The platorms we have forwarded the config entry so far. If a new
# The platforms we have forwarded the config entry so far. If a new
# accessory is added to a bridge we may have to load additional
# platforms. We don't want to load all platforms up front if its just
# a lightbulb. And we don't want to forward a config entry twice

View File

@@ -2,6 +2,7 @@
from dataclasses import dataclass
from aiohttp.client_exceptions import ClientConnectionError
import aiosomecomfort
from homeassistant.config_entries import ConfigEntry
@@ -68,6 +69,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
aiosomecomfort.device.ConnectionError,
aiosomecomfort.device.ConnectionTimeout,
aiosomecomfort.device.SomeComfortError,
ClientConnectionError,
TimeoutError,
) as ex:
raise ConfigEntryNotReady(

View File

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

View File

@@ -10,8 +10,7 @@ import os
import socket
import ssl
from tempfile import NamedTemporaryFile
from typing import Any, Final, Required, TypedDict, cast
from urllib.parse import quote_plus, urljoin
from typing import Any, Final, TypedDict, cast
from aiohttp import web
from aiohttp.abc import AbstractStreamWriter
@@ -30,20 +29,8 @@ from yarl import URL
from homeassistant.components.network import async_get_source_ip
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, SERVER_PORT
from homeassistant.core import (
Event,
HomeAssistant,
ServiceCall,
ServiceResponse,
SupportsResponse,
callback,
)
from homeassistant.exceptions import (
HomeAssistantError,
ServiceValidationError,
Unauthorized,
UnknownUser,
)
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import storage
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.http import (
@@ -66,14 +53,9 @@ from homeassistant.util import dt as dt_util, ssl as ssl_util
from homeassistant.util.async_ import create_eager_task
from homeassistant.util.json import json_loads
from .auth import async_setup_auth, async_sign_path
from .auth import async_setup_auth
from .ban import setup_bans
from .const import ( # noqa: F401
DOMAIN,
KEY_HASS_REFRESH_TOKEN_ID,
KEY_HASS_USER,
StrictConnectionMode,
)
from .const import DOMAIN, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER # noqa: F401
from .cors import setup_cors
from .decorators import require_admin # noqa: F401
from .forwarded import async_setup_forwarded
@@ -96,7 +78,6 @@ CONF_TRUSTED_PROXIES: Final = "trusted_proxies"
CONF_LOGIN_ATTEMPTS_THRESHOLD: Final = "login_attempts_threshold"
CONF_IP_BAN_ENABLED: Final = "ip_ban_enabled"
CONF_SSL_PROFILE: Final = "ssl_profile"
CONF_STRICT_CONNECTION: Final = "strict_connection"
SSL_MODERN: Final = "modern"
SSL_INTERMEDIATE: Final = "intermediate"
@@ -146,9 +127,6 @@ HTTP_SCHEMA: Final = vol.All(
[SSL_INTERMEDIATE, SSL_MODERN]
),
vol.Optional(CONF_USE_X_FRAME_OPTIONS, default=True): cv.boolean,
vol.Optional(
CONF_STRICT_CONNECTION, default=StrictConnectionMode.DISABLED
): vol.Coerce(StrictConnectionMode),
}
),
)
@@ -172,7 +150,6 @@ class ConfData(TypedDict, total=False):
login_attempts_threshold: int
ip_ban_enabled: bool
ssl_profile: str
strict_connection: Required[StrictConnectionMode]
@bind_hass
@@ -241,7 +218,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
login_threshold=login_threshold,
is_ban_enabled=is_ban_enabled,
use_x_frame_options=use_x_frame_options,
strict_connection_non_cloud=conf[CONF_STRICT_CONNECTION],
)
async def stop_server(event: Event) -> None:
@@ -271,7 +247,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
local_ip, host, server_port, ssl_certificate is not None
)
_setup_services(hass, conf)
return True
@@ -356,7 +331,6 @@ class HomeAssistantHTTP:
login_threshold: int,
is_ban_enabled: bool,
use_x_frame_options: bool,
strict_connection_non_cloud: StrictConnectionMode,
) -> None:
"""Initialize the server."""
self.app[KEY_HASS] = self.hass
@@ -373,7 +347,7 @@ class HomeAssistantHTTP:
if is_ban_enabled:
setup_bans(self.hass, self.app, login_threshold)
await async_setup_auth(self.hass, self.app, strict_connection_non_cloud)
await async_setup_auth(self.hass, self.app)
setup_headers(self.app, use_x_frame_options)
setup_cors(self.app, cors_origins)
@@ -602,61 +576,3 @@ async def start_http_server_and_save_config(
]
store.async_delay_save(lambda: conf, SAVE_DELAY)
@callback
def _setup_services(hass: HomeAssistant, conf: ConfData) -> None:
"""Set up services for HTTP component."""
async def create_temporary_strict_connection_url(
call: ServiceCall,
) -> ServiceResponse:
"""Create a strict connection url and return it."""
# Copied form homeassistant/helpers/service.py#_async_admin_handler
# as the helper supports no responses yet
if call.context.user_id:
user = await hass.auth.async_get_user(call.context.user_id)
if user is None:
raise UnknownUser(context=call.context)
if not user.is_admin:
raise Unauthorized(context=call.context)
if conf[CONF_STRICT_CONNECTION] is StrictConnectionMode.DISABLED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="strict_connection_not_enabled_non_cloud",
)
try:
url = get_url(
hass, prefer_external=True, allow_internal=False, allow_cloud=False
)
except NoURLAvailableError as ex:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="no_external_url_available",
) from ex
# to avoid circular import
# pylint: disable-next=import-outside-toplevel
from homeassistant.components.auth import STRICT_CONNECTION_URL
path = async_sign_path(
hass,
STRICT_CONNECTION_URL,
datetime.timedelta(hours=1),
use_content_user=True,
)
url = urljoin(url, path)
return {
"url": f"https://login.home-assistant.io?u={quote_plus(url)}",
"direct_url": url,
}
hass.services.async_register(
DOMAIN,
"create_temporary_strict_connection_url",
create_temporary_strict_connection_url,
supports_response=SupportsResponse.ONLY,
)

View File

@@ -4,18 +4,14 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable
from datetime import timedelta
from http import HTTPStatus
from ipaddress import ip_address
import logging
import os
import secrets
import time
from typing import Any, Final
from aiohttp import hdrs
from aiohttp.web import Application, Request, Response, StreamResponse, middleware
from aiohttp.web_exceptions import HTTPBadRequest
from aiohttp_session import session_middleware
from aiohttp.web import Application, Request, StreamResponse, middleware
import jwt
from jwt import api_jws
from yarl import URL
@@ -25,21 +21,13 @@ from homeassistant.auth.const import GROUP_ID_READ_ONLY
from homeassistant.auth.models import User
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import singleton
from homeassistant.helpers.http import current_request
from homeassistant.helpers.json import json_bytes
from homeassistant.helpers.network import is_cloud_connection
from homeassistant.helpers.storage import Store
from homeassistant.util.network import is_local
from .const import (
DOMAIN,
KEY_AUTHENTICATED,
KEY_HASS_REFRESH_TOKEN_ID,
KEY_HASS_USER,
StrictConnectionMode,
)
from .session import HomeAssistantCookieStorage
from .const import KEY_AUTHENTICATED, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER
_LOGGER = logging.getLogger(__name__)
@@ -51,11 +39,6 @@ SAFE_QUERY_PARAMS: Final = ["height", "width"]
STORAGE_VERSION = 1
STORAGE_KEY = "http.auth"
CONTENT_USER_NAME = "Home Assistant Content"
STRICT_CONNECTION_EXCLUDED_PATH = "/api/webhook/"
STRICT_CONNECTION_GUARD_PAGE_NAME = "strict_connection_guard_page.html"
STRICT_CONNECTION_GUARD_PAGE = os.path.join(
os.path.dirname(__file__), STRICT_CONNECTION_GUARD_PAGE_NAME
)
@callback
@@ -137,7 +120,6 @@ def async_user_not_allowed_do_auth(
async def async_setup_auth(
hass: HomeAssistant,
app: Application,
strict_connection_mode_non_cloud: StrictConnectionMode,
) -> None:
"""Create auth middleware for the app."""
store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY)
@@ -160,10 +142,6 @@ async def async_setup_auth(
hass.data[STORAGE_KEY] = refresh_token.id
if strict_connection_mode_non_cloud is StrictConnectionMode.GUARD_PAGE:
# Load the guard page content on setup
await _read_strict_connection_guard_page(hass)
@callback
def async_validate_auth_header(request: Request) -> bool:
"""Test authorization header against access token.
@@ -252,37 +230,6 @@ async def async_setup_auth(
authenticated = True
auth_type = "signed request"
if not authenticated and not request.path.startswith(
STRICT_CONNECTION_EXCLUDED_PATH
):
strict_connection_mode = strict_connection_mode_non_cloud
strict_connection_func = (
_async_perform_strict_connection_action_on_non_local
)
if is_cloud_connection(hass):
from homeassistant.components.cloud.util import ( # pylint: disable=import-outside-toplevel
get_strict_connection_mode,
)
strict_connection_mode = get_strict_connection_mode(hass)
strict_connection_func = _async_perform_strict_connection_action
if (
strict_connection_mode is not StrictConnectionMode.DISABLED
and not await hass.auth.session.async_validate_request_for_strict_connection_session(
request
)
and (
resp := await strict_connection_func(
hass,
request,
strict_connection_mode is StrictConnectionMode.GUARD_PAGE,
)
)
is not None
):
return resp
if authenticated and _LOGGER.isEnabledFor(logging.DEBUG):
_LOGGER.debug(
"Authenticated %s for %s using %s",
@@ -294,69 +241,4 @@ async def async_setup_auth(
request[KEY_AUTHENTICATED] = authenticated
return await handler(request)
app.middlewares.append(session_middleware(HomeAssistantCookieStorage(hass)))
app.middlewares.append(auth_middleware)
async def _async_perform_strict_connection_action_on_non_local(
hass: HomeAssistant,
request: Request,
guard_page: bool,
) -> StreamResponse | None:
"""Perform strict connection mode action if the request is not local.
The function does the following:
- Try to get the IP address of the request. If it fails, assume it's not local
- If the request is local, return None (allow the request to continue)
- If guard_page is True, return a response with the content
- Otherwise close the connection and raise an exception
"""
try:
ip_address_ = ip_address(request.remote) # type: ignore[arg-type]
except ValueError:
_LOGGER.debug("Invalid IP address: %s", request.remote)
ip_address_ = None
if ip_address_ and is_local(ip_address_):
return None
return await _async_perform_strict_connection_action(hass, request, guard_page)
async def _async_perform_strict_connection_action(
hass: HomeAssistant,
request: Request,
guard_page: bool,
) -> StreamResponse | None:
"""Perform strict connection mode action.
The function does the following:
- If guard_page is True, return a response with the content
- Otherwise close the connection and raise an exception
"""
_LOGGER.debug("Perform strict connection action for %s", request.remote)
if guard_page:
return Response(
text=await _read_strict_connection_guard_page(hass),
content_type="text/html",
status=HTTPStatus.IM_A_TEAPOT,
)
if transport := request.transport:
# it should never happen that we don't have a transport
transport.close()
# We need to raise an exception to stop processing the request
raise HTTPBadRequest
@singleton.singleton(f"{DOMAIN}_{STRICT_CONNECTION_GUARD_PAGE_NAME}")
async def _read_strict_connection_guard_page(hass: HomeAssistant) -> str:
"""Read the strict connection guard page from disk via executor."""
def read_guard_page() -> str:
with open(STRICT_CONNECTION_GUARD_PAGE, encoding="utf-8") as file:
return file.read()
return await hass.async_add_executor_job(read_guard_page)

View File

@@ -1,6 +1,5 @@
"""HTTP specific constants."""
from enum import StrEnum
from typing import Final
from homeassistant.helpers.http import KEY_AUTHENTICATED, KEY_HASS # noqa: F401
@@ -9,11 +8,3 @@ DOMAIN: Final = "http"
KEY_HASS_USER: Final = "hass_user"
KEY_HASS_REFRESH_TOKEN_ID: Final = "hass_refresh_token_id"
class StrictConnectionMode(StrEnum):
"""Enum for strict connection mode."""
DISABLED = "disabled"
GUARD_PAGE = "guard_page"
DROP_CONNECTION = "drop_connection"

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__(
self,
runner: web.BaseRunner,
host: None | str | list[str],
host: str | list[str] | None,
port: int,
*,
ssl_context: SSLContext | None = None,

View File

@@ -303,7 +303,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = {
key="rsrp",
translation_key="rsrp",
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
# http://www.lte-anbieter.info/technik/rsrp.php
# http://www.lte-anbieter.info/technik/rsrp.php # codespell:ignore technik
icon_fn=lambda x: signal_icon((-110, -95, -80), x),
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -313,7 +313,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = {
key="rsrq",
translation_key="rsrq",
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
# http://www.lte-anbieter.info/technik/rsrq.php
# http://www.lte-anbieter.info/technik/rsrq.php # codespell:ignore technik
icon_fn=lambda x: signal_icon((-11, -8, -5), x),
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -333,7 +333,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = {
key="sinr",
translation_key="sinr",
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
# http://www.lte-anbieter.info/technik/sinr.php
# http://www.lte-anbieter.info/technik/sinr.php # codespell:ignore technik
icon_fn=lambda x: signal_icon((0, 5, 10), x),
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,

View File

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

View File

@@ -12,13 +12,13 @@ from aioautomower.session import AutomowerSession
from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.const import PERCENTAGE, EntityCategory, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .const import DOMAIN, EXECUTION_TIME_DELAY
from .coordinator import AutomowerDataUpdateCoordinator
from .entity import AutomowerControlEntity
@@ -52,10 +52,6 @@ async def async_set_work_area_cutting_height(
await coordinator.api.commands.set_cutting_height_workarea(
mower_id, int(cheight), work_area_id
)
# As there are no updates from the websocket regarding work area changes,
# we need to wait 5s and then poll the API.
await asyncio.sleep(5)
await coordinator.async_request_refresh()
async def async_set_cutting_height(
@@ -189,6 +185,7 @@ class AutomowerWorkAreaNumberEntity(AutomowerControlEntity, NumberEntity):
) -> None:
"""Set up AutomowerNumberEntity."""
super().__init__(mower_id, coordinator)
self.coordinator = coordinator
self.entity_description = description
self.work_area_id = work_area_id
self._attr_unique_id = f"{mower_id}_{work_area_id}_{description.key}"
@@ -221,6 +218,11 @@ class AutomowerWorkAreaNumberEntity(AutomowerControlEntity, NumberEntity):
raise HomeAssistantError(
f"Command couldn't be sent to the command queue: {exception}"
) from exception
else:
# As there are no updates from the websocket regarding work area changes,
# we need to wait 5s and then poll the API.
await asyncio.sleep(EXECUTION_TIME_DELAY)
await self.coordinator.async_request_refresh()
@callback
@@ -238,10 +240,13 @@ def async_remove_entities(
for work_area_id in _work_areas:
uid = f"{mower_id}_{work_area_id}_cutting_height_work_area"
active_work_areas.add(uid)
for entity_entry in er.async_entries_for_config_entry(
entity_reg, config_entry.entry_id
for entity_entry in er.async_entries_for_config_entry(
entity_reg, config_entry.entry_id
):
if (
entity_entry.domain == Platform.NUMBER
and (split := entity_entry.unique_id.split("_"))[0] == mower_id
and split[-1] == "area"
and entity_entry.unique_id not in active_work_areas
):
if entity_entry.unique_id.split("_")[0] == mower_id:
if entity_entry.unique_id.endswith("cutting_height_work_area"):
if entity_entry.unique_id not in active_work_areas:
entity_reg.async_remove(entity_entry.entity_id)
entity_reg.async_remove(entity_entry.entity_id)

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 dataclasses import dataclass

View File

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

View File

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

View File

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

View File

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

View File

@@ -447,7 +447,7 @@ class ISYBinarySensorHeartbeat(ISYNodeEntity, BinarySensorEntity, RestoreEntity)
self._node.control_events.subscribe(self._heartbeat_node_control_handler)
# Start the timer on bootup, so we can change from UNKNOWN to OFF
# Start the timer on boot-up, so we can change from UNKNOWN to OFF
self._restart_timer()
if (last_state := await self.async_get_last_state()) is not None:

View File

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

View File

@@ -396,7 +396,7 @@ class JellyfinSource(MediaSource):
k.get(ITEM_KEY_NAME),
),
)
return [await self._build_series(serie, False) for serie in series]
return [await self._build_series(s, False) for s in series]
async def _build_series(
self, series: dict[str, Any], include_children: bool

View File

@@ -5,41 +5,54 @@ from __future__ import annotations
from hdate import Location
import voluptuous as vol
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
CONF_ELEVATION,
CONF_LANGUAGE,
CONF_LATITUDE,
CONF_LONGITUDE,
CONF_NAME,
CONF_TIME_ZONE,
Platform,
)
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType
DOMAIN = "jewish_calendar"
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
CONF_DIASPORA = "diaspora"
CONF_LANGUAGE = "language"
CONF_CANDLE_LIGHT_MINUTES = "candle_lighting_minutes_before_sunset"
CONF_HAVDALAH_OFFSET_MINUTES = "havdalah_minutes_after_sunset"
CANDLE_LIGHT_DEFAULT = 18
DEFAULT_NAME = "Jewish Calendar"
DEFAULT_CANDLE_LIGHT = 18
DEFAULT_DIASPORA = False
DEFAULT_HAVDALAH_OFFSET_MINUTES = 0
DEFAULT_LANGUAGE = "english"
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
DOMAIN: vol.All(
cv.deprecated(DOMAIN),
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_DIASPORA, default=False): cv.boolean,
vol.Optional(CONF_DIASPORA, default=DEFAULT_DIASPORA): cv.boolean,
vol.Inclusive(CONF_LATITUDE, "coordinates"): cv.latitude,
vol.Inclusive(CONF_LONGITUDE, "coordinates"): cv.longitude,
vol.Optional(CONF_LANGUAGE, default="english"): vol.In(
vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): vol.In(
["hebrew", "english"]
),
vol.Optional(
CONF_CANDLE_LIGHT_MINUTES, default=CANDLE_LIGHT_DEFAULT
CONF_CANDLE_LIGHT_MINUTES, default=DEFAULT_CANDLE_LIGHT
): int,
# Default of 0 means use 8.5 degrees / 'three_stars' time.
vol.Optional(CONF_HAVDALAH_OFFSET_MINUTES, default=0): int,
}
vol.Optional(
CONF_HAVDALAH_OFFSET_MINUTES,
default=DEFAULT_HAVDALAH_OFFSET_MINUTES,
): int,
},
)
},
extra=vol.ALLOW_EXTRA,
@@ -72,37 +85,72 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
if DOMAIN not in config:
return True
name = config[DOMAIN][CONF_NAME]
language = config[DOMAIN][CONF_LANGUAGE]
async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
is_fixable=False,
breaks_in_ha_version="2024.10.0",
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": DEFAULT_NAME,
},
)
latitude = config[DOMAIN].get(CONF_LATITUDE, hass.config.latitude)
longitude = config[DOMAIN].get(CONF_LONGITUDE, hass.config.longitude)
diaspora = config[DOMAIN][CONF_DIASPORA]
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN]
)
)
candle_lighting_offset = config[DOMAIN][CONF_CANDLE_LIGHT_MINUTES]
havdalah_offset = config[DOMAIN][CONF_HAVDALAH_OFFSET_MINUTES]
return True
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Set up a configuration entry for Jewish calendar."""
language = config_entry.data.get(CONF_LANGUAGE, DEFAULT_LANGUAGE)
diaspora = config_entry.data.get(CONF_DIASPORA, DEFAULT_DIASPORA)
candle_lighting_offset = config_entry.data.get(
CONF_CANDLE_LIGHT_MINUTES, DEFAULT_CANDLE_LIGHT
)
havdalah_offset = config_entry.data.get(
CONF_HAVDALAH_OFFSET_MINUTES, DEFAULT_HAVDALAH_OFFSET_MINUTES
)
location = Location(
latitude=latitude,
longitude=longitude,
timezone=hass.config.time_zone,
name=hass.config.location_name,
diaspora=diaspora,
latitude=config_entry.data.get(CONF_LATITUDE, hass.config.latitude),
longitude=config_entry.data.get(CONF_LONGITUDE, hass.config.longitude),
altitude=config_entry.data.get(CONF_ELEVATION, hass.config.elevation),
timezone=config_entry.data.get(CONF_TIME_ZONE, hass.config.time_zone),
)
prefix = get_unique_prefix(
location, language, candle_lighting_offset, havdalah_offset
)
hass.data[DOMAIN] = {
"location": location,
"name": name,
hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = {
"language": language,
"diaspora": diaspora,
"location": location,
"candle_lighting_offset": candle_lighting_offset,
"havdalah_offset": havdalah_offset,
"diaspora": diaspora,
"prefix": prefix,
}
for platform in PLATFORMS:
hass.async_create_task(async_load_platform(hass, platform, DOMAIN, {}, config))
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(
config_entry, PLATFORMS
)
if unload_ok:
hass.data[DOMAIN].pop(config_entry.entry_id)
return unload_ok

View File

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

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",
"name": "Jewish Calendar",
"codeowners": ["@tsvi"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/jewish_calendar",
"iot_class": "calculated",
"loggers": ["hdate"],
"requirements": ["hdate==0.10.8"]
"requirements": ["hdate==0.10.8"],
"single_config_entry": true
}

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,26 +2,17 @@
from __future__ import annotations
from datetime import timedelta
import logging
import aiohttp
from moehlenhoff_alpha2 import Alpha2Base
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
from .coordinator import Alpha2BaseCoordinator
PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR]
UPDATE_INTERVAL = timedelta(seconds=60)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
@@ -51,114 +42,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
class Alpha2BaseCoordinator(DataUpdateCoordinator[dict[str, dict]]): # pylint: disable=hass-enforce-coordinator-module
"""Keep the base instance in one place and centralize the update."""
def __init__(self, hass: HomeAssistant, base: Alpha2Base) -> None:
"""Initialize Alpha2Base data updater."""
self.base = base
super().__init__(
hass=hass,
logger=_LOGGER,
name="alpha2_base",
update_interval=UPDATE_INTERVAL,
)
async def _async_update_data(self) -> dict[str, dict[str, dict]]:
"""Fetch the latest data from the source."""
await self.base.update_data()
return {
"heat_areas": {ha["ID"]: ha for ha in self.base.heat_areas if ha.get("ID")},
"heat_controls": {
hc["ID"]: hc for hc in self.base.heat_controls if hc.get("ID")
},
"io_devices": {io["ID"]: io for io in self.base.io_devices if io.get("ID")},
}
def get_cooling(self) -> bool:
"""Return if cooling mode is enabled."""
return self.base.cooling
async def async_set_cooling(self, enabled: bool) -> None:
"""Enable or disable cooling mode."""
await self.base.set_cooling(enabled)
self.async_update_listeners()
async def async_set_target_temperature(
self, heat_area_id: str, target_temperature: float
) -> None:
"""Set the target temperature of the given heat area."""
_LOGGER.debug(
"Setting target temperature of heat area %s to %0.1f",
heat_area_id,
target_temperature,
)
update_data = {"T_TARGET": target_temperature}
is_cooling = self.get_cooling()
heat_area_mode = self.data["heat_areas"][heat_area_id]["HEATAREA_MODE"]
if heat_area_mode == 1:
if is_cooling:
update_data["T_COOL_DAY"] = target_temperature
else:
update_data["T_HEAT_DAY"] = target_temperature
elif heat_area_mode == 2:
if is_cooling:
update_data["T_COOL_NIGHT"] = target_temperature
else:
update_data["T_HEAT_NIGHT"] = target_temperature
try:
await self.base.update_heat_area(heat_area_id, update_data)
except aiohttp.ClientError as http_err:
raise HomeAssistantError(
"Failed to set target temperature, communication error with alpha2 base"
) from http_err
self.data["heat_areas"][heat_area_id].update(update_data)
self.async_update_listeners()
async def async_set_heat_area_mode(
self, heat_area_id: str, heat_area_mode: int
) -> None:
"""Set the mode of the given heat area."""
# HEATAREA_MODE: 0=Auto, 1=Tag, 2=Nacht
if heat_area_mode not in (0, 1, 2):
raise ValueError(f"Invalid heat area mode: {heat_area_mode}")
_LOGGER.debug(
"Setting mode of heat area %s to %d",
heat_area_id,
heat_area_mode,
)
try:
await self.base.update_heat_area(
heat_area_id, {"HEATAREA_MODE": heat_area_mode}
)
except aiohttp.ClientError as http_err:
raise HomeAssistantError(
"Failed to set heat area mode, communication error with alpha2 base"
) from http_err
self.data["heat_areas"][heat_area_id]["HEATAREA_MODE"] = heat_area_mode
is_cooling = self.get_cooling()
if heat_area_mode == 1:
if is_cooling:
self.data["heat_areas"][heat_area_id]["T_TARGET"] = self.data[
"heat_areas"
][heat_area_id]["T_COOL_DAY"]
else:
self.data["heat_areas"][heat_area_id]["T_TARGET"] = self.data[
"heat_areas"
][heat_area_id]["T_HEAT_DAY"]
elif heat_area_mode == 2:
if is_cooling:
self.data["heat_areas"][heat_area_id]["T_TARGET"] = self.data[
"heat_areas"
][heat_area_id]["T_COOL_NIGHT"]
else:
self.data["heat_areas"][heat_area_id]["T_TARGET"] = self.data[
"heat_areas"
][heat_area_id]["T_HEAT_NIGHT"]
self.async_update_listeners()

View File

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

View File

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

View File

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

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.update_coordinator import CoordinatorEntity
from . import Alpha2BaseCoordinator
from .const import DOMAIN
from .coordinator import Alpha2BaseCoordinator
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(
hass: HomeAssistant, webhook_id: str, request: Request
) -> None | Response:
) -> Response | None:
"""Handle webhook callback."""
try:

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