forked from home-assistant/core
Compare commits
1 Commits
google-llm
...
improve_ge
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d55e183f2 |
5
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
5
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,14 +1,15 @@
|
||||
name: Report an issue with Home Assistant Core
|
||||
description: Report an issue with Home Assistant Core.
|
||||
type: Bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
This issue form is for reporting bugs only!
|
||||
|
||||
If you have a feature or enhancement request, please [request them here instead][fr].
|
||||
If you have a feature or enhancement request, please use the [feature request][fr] section of our [Community Forum][fr].
|
||||
|
||||
[fr]: https://github.com/orgs/home-assistant/discussions
|
||||
[fr]: https://community.home-assistant.io/c/feature-requests
|
||||
- type: textarea
|
||||
validations:
|
||||
required: true
|
||||
|
||||
4
.github/ISSUE_TEMPLATE/config.yml
vendored
4
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -10,8 +10,8 @@ contact_links:
|
||||
url: https://www.home-assistant.io/help
|
||||
about: We use GitHub for tracking bugs, check our website for resources on getting help.
|
||||
- name: Feature Request
|
||||
url: https://github.com/orgs/home-assistant/discussions
|
||||
about: Please use this link to request new features or enhancements to existing features.
|
||||
url: https://community.home-assistant.io/c/feature-requests
|
||||
about: Please use our Community Forum for making feature requests.
|
||||
- name: I'm unsure where to go
|
||||
url: https://www.home-assistant.io/join-chat
|
||||
about: If you are unsure where to go, then joining our chat is recommended; Just ask!
|
||||
|
||||
2
.github/workflows/builder.yml
vendored
2
.github/workflows/builder.yml
vendored
@@ -108,7 +108,7 @@ jobs:
|
||||
uses: dawidd6/action-download-artifact@v11
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
repo: OHF-Voice/intents-package
|
||||
repo: home-assistant/intents-package
|
||||
branch: main
|
||||
workflow: nightly.yaml
|
||||
workflow_conclusion: success
|
||||
|
||||
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -37,7 +37,7 @@ on:
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
CACHE_VERSION: 3
|
||||
CACHE_VERSION: 2
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2025.7"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.12.0
|
||||
rev: v0.11.12
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
args:
|
||||
|
||||
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -57,8 +57,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/aemet/ @Noltari
|
||||
/homeassistant/components/agent_dvr/ @ispysoftware
|
||||
/tests/components/agent_dvr/ @ispysoftware
|
||||
/homeassistant/components/ai_task/ @home-assistant/core
|
||||
/tests/components/ai_task/ @home-assistant/core
|
||||
/homeassistant/components/air_quality/ @home-assistant/core
|
||||
/tests/components/air_quality/ @home-assistant/core
|
||||
/homeassistant/components/airgradient/ @airgradienthq @joostlek
|
||||
|
||||
@@ -38,7 +38,8 @@ def validate_python() -> None:
|
||||
|
||||
def ensure_config_path(config_dir: str) -> None:
|
||||
"""Validate the configuration directory."""
|
||||
from . import config as config_util # noqa: PLC0415
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from . import config as config_util
|
||||
|
||||
lib_dir = os.path.join(config_dir, "deps")
|
||||
|
||||
@@ -79,7 +80,8 @@ def ensure_config_path(config_dir: str) -> None:
|
||||
|
||||
def get_arguments() -> argparse.Namespace:
|
||||
"""Get parsed passed in arguments."""
|
||||
from . import config as config_util # noqa: PLC0415
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from . import config as config_util
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Home Assistant: Observe, Control, Automate.",
|
||||
@@ -175,7 +177,8 @@ def main() -> int:
|
||||
validate_os()
|
||||
|
||||
if args.script is not None:
|
||||
from . import scripts # noqa: PLC0415
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from . import scripts
|
||||
|
||||
return scripts.run(args.script)
|
||||
|
||||
@@ -185,7 +188,8 @@ def main() -> int:
|
||||
|
||||
ensure_config_path(config_dir)
|
||||
|
||||
from . import config, runner # noqa: PLC0415
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from . import config, runner
|
||||
|
||||
safe_mode = config.safe_mode_enabled(config_dir)
|
||||
|
||||
|
||||
@@ -52,28 +52,28 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
def _generate_secret() -> str:
|
||||
"""Generate a secret."""
|
||||
import pyotp # noqa: PLC0415
|
||||
import pyotp # pylint: disable=import-outside-toplevel
|
||||
|
||||
return str(pyotp.random_base32())
|
||||
|
||||
|
||||
def _generate_random() -> int:
|
||||
"""Generate a 32 digit number."""
|
||||
import pyotp # noqa: PLC0415
|
||||
import pyotp # pylint: disable=import-outside-toplevel
|
||||
|
||||
return int(pyotp.random_base32(length=32, chars=list("1234567890")))
|
||||
|
||||
|
||||
def _generate_otp(secret: str, count: int) -> str:
|
||||
"""Generate one time password."""
|
||||
import pyotp # noqa: PLC0415
|
||||
import pyotp # pylint: disable=import-outside-toplevel
|
||||
|
||||
return str(pyotp.HOTP(secret).at(count))
|
||||
|
||||
|
||||
def _verify_otp(secret: str, otp: str, count: int) -> bool:
|
||||
"""Verify one time password."""
|
||||
import pyotp # noqa: PLC0415
|
||||
import pyotp # pylint: disable=import-outside-toplevel
|
||||
|
||||
return bool(pyotp.HOTP(secret).verify(otp, count))
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ DUMMY_SECRET = "FPPTH34D4E3MI2HG"
|
||||
|
||||
def _generate_qr_code(data: str) -> str:
|
||||
"""Generate a base64 PNG string represent QR Code image of data."""
|
||||
import pyqrcode # noqa: PLC0415
|
||||
import pyqrcode # pylint: disable=import-outside-toplevel
|
||||
|
||||
qr_code = pyqrcode.create(data)
|
||||
|
||||
@@ -59,7 +59,7 @@ def _generate_qr_code(data: str) -> str:
|
||||
|
||||
def _generate_secret_and_qr_code(username: str) -> tuple[str, str, str]:
|
||||
"""Generate a secret, url, and QR code."""
|
||||
import pyotp # noqa: PLC0415
|
||||
import pyotp # pylint: disable=import-outside-toplevel
|
||||
|
||||
ota_secret = pyotp.random_base32()
|
||||
url = pyotp.totp.TOTP(ota_secret).provisioning_uri(
|
||||
@@ -107,7 +107,7 @@ class TotpAuthModule(MultiFactorAuthModule):
|
||||
|
||||
def _add_ota_secret(self, user_id: str, secret: str | None = None) -> str:
|
||||
"""Create a ota_secret for user."""
|
||||
import pyotp # noqa: PLC0415
|
||||
import pyotp # pylint: disable=import-outside-toplevel
|
||||
|
||||
ota_secret: str = secret or pyotp.random_base32()
|
||||
|
||||
@@ -163,7 +163,7 @@ class TotpAuthModule(MultiFactorAuthModule):
|
||||
|
||||
def _validate_2fa(self, user_id: str, code: str) -> bool:
|
||||
"""Validate two factor authentication code."""
|
||||
import pyotp # noqa: PLC0415
|
||||
import pyotp # pylint: disable=import-outside-toplevel
|
||||
|
||||
if (ota_secret := self._users.get(user_id)) is None: # type: ignore[union-attr]
|
||||
# even we cannot find user, we still do verify
|
||||
@@ -196,7 +196,7 @@ class TotpSetupFlow(SetupFlow[TotpAuthModule]):
|
||||
Return self.async_show_form(step_id='init') if user_input is None.
|
||||
Return self.async_create_entry(data={'result': result}) if finish.
|
||||
"""
|
||||
import pyotp # noqa: PLC0415
|
||||
import pyotp # pylint: disable=import-outside-toplevel
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
|
||||
@@ -394,7 +394,7 @@ async def async_setup_hass(
|
||||
|
||||
def open_hass_ui(hass: core.HomeAssistant) -> None:
|
||||
"""Open the UI."""
|
||||
import webbrowser # noqa: PLC0415
|
||||
import webbrowser # pylint: disable=import-outside-toplevel
|
||||
|
||||
if hass.config.api is None or "frontend" not in hass.config.components:
|
||||
_LOGGER.warning("Cannot launch the UI because frontend not loaded")
|
||||
@@ -561,7 +561,8 @@ async def async_enable_logging(
|
||||
|
||||
if not log_no_color:
|
||||
try:
|
||||
from colorlog import ColoredFormatter # noqa: PLC0415
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from colorlog import ColoredFormatter
|
||||
|
||||
# basicConfig must be called after importing colorlog in order to
|
||||
# ensure that the handlers it sets up wraps the correct streams.
|
||||
@@ -605,7 +606,7 @@ async def async_enable_logging(
|
||||
)
|
||||
threading.excepthook = lambda args: logging.getLogger().exception(
|
||||
"Uncaught thread exception",
|
||||
exc_info=( # type: ignore[arg-type] # noqa: LOG014
|
||||
exc_info=( # type: ignore[arg-type]
|
||||
args.exc_type,
|
||||
args.exc_value,
|
||||
args.exc_traceback,
|
||||
@@ -1059,5 +1060,5 @@ async def _async_setup_multi_components(
|
||||
_LOGGER.error(
|
||||
"Error setting up integration %s - received exception",
|
||||
domain,
|
||||
exc_info=(type(result), result, result.__traceback__), # noqa: LOG014
|
||||
exc_info=(type(result), result, result.__traceback__),
|
||||
)
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
"""Integration to offer AI tasks to Home Assistant."""
|
||||
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import (
|
||||
HassJobType,
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
ServiceResponse,
|
||||
SupportsResponse,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv, storage
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType
|
||||
|
||||
from .const import DATA_COMPONENT, DATA_PREFERENCES, DOMAIN, AITaskEntityFeature
|
||||
from .entity import AITaskEntity
|
||||
from .http import async_setup as async_setup_conversation_http
|
||||
from .task import GenTextTask, GenTextTaskResult, async_generate_text
|
||||
|
||||
__all__ = [
|
||||
"DOMAIN",
|
||||
"AITaskEntity",
|
||||
"AITaskEntityFeature",
|
||||
"GenTextTask",
|
||||
"GenTextTaskResult",
|
||||
"async_generate_text",
|
||||
"async_setup",
|
||||
"async_setup_entry",
|
||||
"async_unload_entry",
|
||||
]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Register the process service."""
|
||||
entity_component = EntityComponent[AITaskEntity](_LOGGER, DOMAIN, hass)
|
||||
hass.data[DATA_COMPONENT] = entity_component
|
||||
hass.data[DATA_PREFERENCES] = AITaskPreferences(hass)
|
||||
await hass.data[DATA_PREFERENCES].async_load()
|
||||
async_setup_conversation_http(hass)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"generate_text",
|
||||
async_service_generate_text,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required("task_name"): cv.string,
|
||||
vol.Optional("entity_id"): cv.entity_id,
|
||||
vol.Required("instructions"): cv.string,
|
||||
}
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
job_type=HassJobType.Coroutinefunction,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up a config entry."""
|
||||
return await hass.data[DATA_COMPONENT].async_setup_entry(entry)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
|
||||
|
||||
|
||||
async def async_service_generate_text(call: ServiceCall) -> ServiceResponse:
|
||||
"""Run the run task service."""
|
||||
result = await async_generate_text(hass=call.hass, **call.data)
|
||||
return result.as_dict() # type: ignore[return-value]
|
||||
|
||||
|
||||
class AITaskPreferences:
|
||||
"""AI Task preferences."""
|
||||
|
||||
KEYS = ("gen_text_entity_id",)
|
||||
|
||||
gen_text_entity_id: str | None = None
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the preferences."""
|
||||
self._store: storage.Store[dict[str, str | None]] = storage.Store(
|
||||
hass, 1, DOMAIN
|
||||
)
|
||||
|
||||
async def async_load(self) -> None:
|
||||
"""Load the data from the store."""
|
||||
data = await self._store.async_load()
|
||||
if data is None:
|
||||
return
|
||||
for key in self.KEYS:
|
||||
setattr(self, key, data[key])
|
||||
|
||||
@callback
|
||||
def async_set_preferences(
|
||||
self,
|
||||
*,
|
||||
gen_text_entity_id: str | None | UndefinedType = UNDEFINED,
|
||||
) -> None:
|
||||
"""Set the preferences."""
|
||||
changed = False
|
||||
for key, value in (("gen_text_entity_id", gen_text_entity_id),):
|
||||
if value is not UNDEFINED:
|
||||
if getattr(self, key) != value:
|
||||
setattr(self, key, value)
|
||||
changed = True
|
||||
|
||||
if not changed:
|
||||
return
|
||||
|
||||
self._store.async_delay_save(self.as_dict, 10)
|
||||
|
||||
@callback
|
||||
def as_dict(self) -> dict[str, str | None]:
|
||||
"""Get the current preferences."""
|
||||
return {key: getattr(self, key) for key in self.KEYS}
|
||||
@@ -1,29 +0,0 @@
|
||||
"""Constants for the AI Task integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import IntFlag
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
|
||||
from . import AITaskPreferences
|
||||
from .entity import AITaskEntity
|
||||
|
||||
DOMAIN = "ai_task"
|
||||
DATA_COMPONENT: HassKey[EntityComponent[AITaskEntity]] = HassKey(DOMAIN)
|
||||
DATA_PREFERENCES: HassKey[AITaskPreferences] = HassKey(f"{DOMAIN}_preferences")
|
||||
|
||||
DEFAULT_SYSTEM_PROMPT = (
|
||||
"You are a Home Assistant expert and help users with their tasks."
|
||||
)
|
||||
|
||||
|
||||
class AITaskEntityFeature(IntFlag):
|
||||
"""Supported features of the AI task entity."""
|
||||
|
||||
GENERATE_TEXT = 1
|
||||
"""Generate text based on instructions."""
|
||||
@@ -1,103 +0,0 @@
|
||||
"""Entity for the AI Task integration."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
import contextlib
|
||||
from typing import final
|
||||
|
||||
from propcache.api import cached_property
|
||||
|
||||
from homeassistant.components.conversation import (
|
||||
ChatLog,
|
||||
UserContent,
|
||||
async_get_chat_log,
|
||||
)
|
||||
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
from homeassistant.helpers import llm
|
||||
from homeassistant.helpers.chat_session import async_get_chat_session
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DEFAULT_SYSTEM_PROMPT, DOMAIN, AITaskEntityFeature
|
||||
from .task import GenTextTask, GenTextTaskResult
|
||||
|
||||
|
||||
class AITaskEntity(RestoreEntity):
|
||||
"""Entity that supports conversations."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_supported_features = AITaskEntityFeature(0)
|
||||
__last_activity: str | None = None
|
||||
|
||||
@property
|
||||
@final
|
||||
def state(self) -> str | None:
|
||||
"""Return the state of the entity."""
|
||||
if self.__last_activity is None:
|
||||
return None
|
||||
return self.__last_activity
|
||||
|
||||
@cached_property
|
||||
def supported_features(self) -> AITaskEntityFeature:
|
||||
"""Flag supported features."""
|
||||
return self._attr_supported_features
|
||||
|
||||
async def async_internal_added_to_hass(self) -> None:
|
||||
"""Call when the entity is added to hass."""
|
||||
await super().async_internal_added_to_hass()
|
||||
state = await self.async_get_last_state()
|
||||
if (
|
||||
state is not None
|
||||
and state.state is not None
|
||||
and state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
|
||||
):
|
||||
self.__last_activity = state.state
|
||||
|
||||
@final
|
||||
@contextlib.asynccontextmanager
|
||||
async def _async_get_ai_task_chat_log(
|
||||
self,
|
||||
task: GenTextTask,
|
||||
) -> AsyncGenerator[ChatLog]:
|
||||
"""Context manager used to manage the ChatLog used during an AI Task."""
|
||||
# pylint: disable-next=contextmanager-generator-missing-cleanup
|
||||
with (
|
||||
async_get_chat_session(self.hass) as session,
|
||||
async_get_chat_log(
|
||||
self.hass,
|
||||
session,
|
||||
None,
|
||||
) as chat_log,
|
||||
):
|
||||
await chat_log.async_provide_llm_data(
|
||||
llm.LLMContext(
|
||||
platform=self.platform.domain,
|
||||
context=None,
|
||||
language=None,
|
||||
assistant=DOMAIN,
|
||||
device_id=None,
|
||||
),
|
||||
user_llm_prompt=DEFAULT_SYSTEM_PROMPT,
|
||||
)
|
||||
|
||||
chat_log.async_add_user_content(UserContent(task.instructions))
|
||||
|
||||
yield chat_log
|
||||
|
||||
@final
|
||||
async def internal_async_generate_text(
|
||||
self,
|
||||
task: GenTextTask,
|
||||
) -> GenTextTaskResult:
|
||||
"""Run a gen text task."""
|
||||
self.__last_activity = dt_util.utcnow().isoformat()
|
||||
self.async_write_ha_state()
|
||||
async with self._async_get_ai_task_chat_log(task) as chat_log:
|
||||
return await self._async_generate_text(task, chat_log)
|
||||
|
||||
async def _async_generate_text(
|
||||
self,
|
||||
task: GenTextTask,
|
||||
chat_log: ChatLog,
|
||||
) -> GenTextTaskResult:
|
||||
"""Handle a gen text task."""
|
||||
raise NotImplementedError
|
||||
@@ -1,54 +0,0 @@
|
||||
"""HTTP endpoint for AI Task integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .const import DATA_PREFERENCES
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup(hass: HomeAssistant) -> None:
|
||||
"""Set up the HTTP API for the conversation integration."""
|
||||
websocket_api.async_register_command(hass, websocket_get_preferences)
|
||||
websocket_api.async_register_command(hass, websocket_set_preferences)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "ai_task/preferences/get",
|
||||
}
|
||||
)
|
||||
@callback
|
||||
def websocket_get_preferences(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Get AI task preferences."""
|
||||
preferences = hass.data[DATA_PREFERENCES]
|
||||
connection.send_result(msg["id"], preferences.as_dict())
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "ai_task/preferences/set",
|
||||
vol.Optional("gen_text_entity_id"): vol.Any(str, None),
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@callback
|
||||
def websocket_set_preferences(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Set AI task preferences."""
|
||||
preferences = hass.data[DATA_PREFERENCES]
|
||||
msg.pop("type")
|
||||
msg_id = msg.pop("id")
|
||||
preferences.async_set_preferences(**msg)
|
||||
connection.send_result(msg_id, preferences.as_dict())
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"services": {
|
||||
"generate_text": {
|
||||
"service": "mdi:file-star-four-points-outline"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"domain": "ai_task",
|
||||
"name": "AI Task",
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"dependencies": ["conversation"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/ai_task",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal"
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
generate_text:
|
||||
fields:
|
||||
task_name:
|
||||
example: "home summary"
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
instructions:
|
||||
example: "Generate a funny notification that garage door was left open"
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
entity_id:
|
||||
required: false
|
||||
selector:
|
||||
entity:
|
||||
domain: ai_task
|
||||
supported_features:
|
||||
- ai_task.AITaskEntityFeature.GENERATE_TEXT
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"services": {
|
||||
"generate_text": {
|
||||
"name": "Generate text",
|
||||
"description": "Use AI to run a task that generates text.",
|
||||
"fields": {
|
||||
"task_name": {
|
||||
"name": "Task Name",
|
||||
"description": "Name of the task."
|
||||
},
|
||||
"instructions": {
|
||||
"name": "Instructions",
|
||||
"description": "Instructions on what needs to be done."
|
||||
},
|
||||
"entity_id": {
|
||||
"name": "Entity ID",
|
||||
"description": "Entity ID to run the task on. If not provided, the preferred entity will be used."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
"""AI tasks to be handled by agents."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DATA_COMPONENT, DATA_PREFERENCES, AITaskEntityFeature
|
||||
|
||||
|
||||
async def async_generate_text(
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
task_name: str,
|
||||
entity_id: str | None = None,
|
||||
instructions: str,
|
||||
) -> GenTextTaskResult:
|
||||
"""Run a task in the AI Task integration."""
|
||||
if entity_id is None:
|
||||
entity_id = hass.data[DATA_PREFERENCES].gen_text_entity_id
|
||||
|
||||
if entity_id is None:
|
||||
raise ValueError("No entity_id provided and no preferred entity set")
|
||||
|
||||
entity = hass.data[DATA_COMPONENT].get_entity(entity_id)
|
||||
if entity is None:
|
||||
raise ValueError(f"AI Task entity {entity_id} not found")
|
||||
|
||||
if AITaskEntityFeature.GENERATE_TEXT not in entity.supported_features:
|
||||
raise ValueError(f"AI Task entity {entity_id} does not support generating text")
|
||||
|
||||
return await entity.internal_async_generate_text(
|
||||
GenTextTask(
|
||||
name=task_name,
|
||||
instructions=instructions,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GenTextTask:
|
||||
"""Gen text task to be processed."""
|
||||
|
||||
name: str
|
||||
"""Name of the task."""
|
||||
|
||||
instructions: str
|
||||
"""Instructions on what needs to be done."""
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Return task as a string."""
|
||||
return f"<GenTextTask {self.name}: {id(self)}>"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GenTextTaskResult:
|
||||
"""Result of gen text task."""
|
||||
|
||||
conversation_id: str
|
||||
"""Unique identifier for the conversation."""
|
||||
|
||||
text: str
|
||||
"""Generated text."""
|
||||
|
||||
def as_dict(self) -> dict[str, str]:
|
||||
"""Return result as a dict."""
|
||||
return {
|
||||
"conversation_id": self.conversation_id,
|
||||
"text": self.text,
|
||||
}
|
||||
@@ -39,14 +39,14 @@ class AirlyFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
try:
|
||||
location_point_valid = await check_location(
|
||||
location_point_valid = await test_location(
|
||||
websession,
|
||||
user_input["api_key"],
|
||||
user_input["latitude"],
|
||||
user_input["longitude"],
|
||||
)
|
||||
if not location_point_valid:
|
||||
location_nearest_valid = await check_location(
|
||||
location_nearest_valid = await test_location(
|
||||
websession,
|
||||
user_input["api_key"],
|
||||
user_input["latitude"],
|
||||
@@ -88,7 +88,7 @@ class AirlyFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
|
||||
|
||||
async def check_location(
|
||||
async def test_location(
|
||||
client: ClientSession,
|
||||
api_key: str,
|
||||
latitude: float,
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["aioamazondevices==3.1.14"]
|
||||
"requirements": ["aioamazondevices==3.1.4"]
|
||||
}
|
||||
|
||||
@@ -1,23 +1,13 @@
|
||||
"""Base class for assist satellite entities."""
|
||||
|
||||
from dataclasses import asdict
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from hassil.util import (
|
||||
PUNCTUATION_END,
|
||||
PUNCTUATION_END_WORD,
|
||||
PUNCTUATION_START,
|
||||
PUNCTUATION_START_WORD,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
@@ -33,7 +23,6 @@ from .const import (
|
||||
)
|
||||
from .entity import (
|
||||
AssistSatelliteAnnouncement,
|
||||
AssistSatelliteAnswer,
|
||||
AssistSatelliteConfiguration,
|
||||
AssistSatelliteEntity,
|
||||
AssistSatelliteEntityDescription,
|
||||
@@ -45,7 +34,6 @@ from .websocket_api import async_register_websocket_api
|
||||
__all__ = [
|
||||
"DOMAIN",
|
||||
"AssistSatelliteAnnouncement",
|
||||
"AssistSatelliteAnswer",
|
||||
"AssistSatelliteConfiguration",
|
||||
"AssistSatelliteEntity",
|
||||
"AssistSatelliteEntityDescription",
|
||||
@@ -98,62 +86,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"async_internal_start_conversation",
|
||||
[AssistSatelliteEntityFeature.START_CONVERSATION],
|
||||
)
|
||||
|
||||
async def handle_ask_question(call: ServiceCall) -> dict[str, Any]:
|
||||
"""Handle a Show View service call."""
|
||||
satellite_entity_id: str = call.data[ATTR_ENTITY_ID]
|
||||
satellite_entity: AssistSatelliteEntity | None = component.get_entity(
|
||||
satellite_entity_id
|
||||
)
|
||||
if satellite_entity is None:
|
||||
raise HomeAssistantError(
|
||||
f"Invalid Assist satellite entity id: {satellite_entity_id}"
|
||||
)
|
||||
|
||||
ask_question_args = {
|
||||
"question": call.data.get("question"),
|
||||
"question_media_id": call.data.get("question_media_id"),
|
||||
"preannounce": call.data.get("preannounce", False),
|
||||
"answers": call.data.get("answers"),
|
||||
}
|
||||
|
||||
if preannounce_media_id := call.data.get("preannounce_media_id"):
|
||||
ask_question_args["preannounce_media_id"] = preannounce_media_id
|
||||
|
||||
answer = await satellite_entity.async_internal_ask_question(**ask_question_args)
|
||||
|
||||
if answer is None:
|
||||
raise HomeAssistantError("No answer from satellite")
|
||||
|
||||
return asdict(answer)
|
||||
|
||||
hass.services.async_register(
|
||||
domain=DOMAIN,
|
||||
service="ask_question",
|
||||
service_func=handle_ask_question,
|
||||
schema=vol.All(
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): cv.entity_domain(DOMAIN),
|
||||
vol.Optional("question"): str,
|
||||
vol.Optional("question_media_id"): str,
|
||||
vol.Optional("preannounce"): bool,
|
||||
vol.Optional("preannounce_media_id"): str,
|
||||
vol.Optional("answers"): [
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
vol.Required("sentences"): vol.All(
|
||||
cv.ensure_list,
|
||||
[cv.string],
|
||||
has_one_non_empty_item,
|
||||
has_no_punctuation,
|
||||
),
|
||||
}
|
||||
],
|
||||
},
|
||||
cv.has_at_least_one_key("question", "question_media_id"),
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
hass.data[CONNECTION_TEST_DATA] = {}
|
||||
async_register_websocket_api(hass)
|
||||
hass.http.register_view(ConnectionTestView())
|
||||
@@ -178,29 +110,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
|
||||
|
||||
|
||||
def has_no_punctuation(value: list[str]) -> list[str]:
|
||||
"""Validate result does not contain punctuation."""
|
||||
for sentence in value:
|
||||
if (
|
||||
PUNCTUATION_START.search(sentence)
|
||||
or PUNCTUATION_END.search(sentence)
|
||||
or PUNCTUATION_START_WORD.search(sentence)
|
||||
or PUNCTUATION_END_WORD.search(sentence)
|
||||
):
|
||||
raise vol.Invalid("sentence should not contain punctuation")
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def has_one_non_empty_item(value: list[str]) -> list[str]:
|
||||
"""Validate result has at least one item."""
|
||||
if len(value) < 1:
|
||||
raise vol.Invalid("at least one sentence is required")
|
||||
|
||||
for sentence in value:
|
||||
if not sentence:
|
||||
raise vol.Invalid("sentences cannot be empty")
|
||||
|
||||
return value
|
||||
|
||||
@@ -4,16 +4,12 @@ from abc import abstractmethod
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterable
|
||||
import contextlib
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Literal, final
|
||||
|
||||
from hassil import Intents, recognize
|
||||
from hassil.expression import Expression, ListReference, Sequence
|
||||
from hassil.intents import WildcardSlotList
|
||||
|
||||
from homeassistant.components import conversation, media_source, stt, tts
|
||||
from homeassistant.components.assist_pipeline import (
|
||||
OPTION_PREFERRED,
|
||||
@@ -109,20 +105,6 @@ class AssistSatelliteAnnouncement:
|
||||
"""Media ID to be played before announcement."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssistSatelliteAnswer:
|
||||
"""Answer to a question."""
|
||||
|
||||
id: str | None
|
||||
"""Matched answer id or None if no answer was matched."""
|
||||
|
||||
sentence: str
|
||||
"""Raw sentence text from user response."""
|
||||
|
||||
slots: dict[str, Any] = field(default_factory=dict)
|
||||
"""Matched slots from answer."""
|
||||
|
||||
|
||||
class AssistSatelliteEntity(entity.Entity):
|
||||
"""Entity encapsulating the state and functionality of an Assist satellite."""
|
||||
|
||||
@@ -140,7 +122,6 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
_wake_word_intercept_future: asyncio.Future[str | None] | None = None
|
||||
_attr_tts_options: dict[str, Any] | None = None
|
||||
_pipeline_task: asyncio.Task | None = None
|
||||
_ask_question_future: asyncio.Future[str | None] | None = None
|
||||
|
||||
__assist_satellite_state = AssistSatelliteState.IDLE
|
||||
|
||||
@@ -328,112 +309,6 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
"""Start a conversation from the satellite."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_internal_ask_question(
|
||||
self,
|
||||
question: str | None = None,
|
||||
question_media_id: str | None = None,
|
||||
preannounce: bool = True,
|
||||
preannounce_media_id: str = PREANNOUNCE_URL,
|
||||
answers: list[dict[str, Any]] | None = None,
|
||||
) -> AssistSatelliteAnswer | None:
|
||||
"""Ask a question and get a user's response from the satellite.
|
||||
|
||||
If question_media_id is not provided, question is synthesized to audio
|
||||
with the selected pipeline.
|
||||
|
||||
If question_media_id is provided, it is played directly. It is possible
|
||||
to omit the message and the satellite will not show any text.
|
||||
|
||||
If preannounce is True, a sound is played before the start message or media.
|
||||
If preannounce_media_id is provided, it overrides the default sound.
|
||||
|
||||
Calls async_start_conversation.
|
||||
"""
|
||||
await self._cancel_running_pipeline()
|
||||
|
||||
if question is None:
|
||||
question = ""
|
||||
|
||||
announcement = await self._resolve_announcement_media_id(
|
||||
question,
|
||||
question_media_id,
|
||||
preannounce_media_id=preannounce_media_id if preannounce else None,
|
||||
)
|
||||
|
||||
if self._is_announcing:
|
||||
raise SatelliteBusyError
|
||||
|
||||
self._is_announcing = True
|
||||
self._set_state(AssistSatelliteState.RESPONDING)
|
||||
self._ask_question_future = asyncio.Future()
|
||||
|
||||
try:
|
||||
# Wait for announcement to finish
|
||||
await self.async_start_conversation(announcement)
|
||||
|
||||
# Wait for response text
|
||||
response_text = await self._ask_question_future
|
||||
if response_text is None:
|
||||
raise HomeAssistantError("No answer from question")
|
||||
|
||||
if not answers:
|
||||
return AssistSatelliteAnswer(id=None, sentence=response_text)
|
||||
|
||||
return self._question_response_to_answer(response_text, answers)
|
||||
finally:
|
||||
self._is_announcing = False
|
||||
self._set_state(AssistSatelliteState.IDLE)
|
||||
self._ask_question_future = None
|
||||
|
||||
def _question_response_to_answer(
|
||||
self, response_text: str, answers: list[dict[str, Any]]
|
||||
) -> AssistSatelliteAnswer:
|
||||
"""Match text to a pre-defined set of answers."""
|
||||
|
||||
# Build intents and match
|
||||
intents = Intents.from_dict(
|
||||
{
|
||||
"language": self.hass.config.language,
|
||||
"intents": {
|
||||
"QuestionIntent": {
|
||||
"data": [
|
||||
{
|
||||
"sentences": answer["sentences"],
|
||||
"metadata": {"answer_id": answer["id"]},
|
||||
}
|
||||
for answer in answers
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Assume slot list references are wildcards
|
||||
wildcard_names: set[str] = set()
|
||||
for intent in intents.intents.values():
|
||||
for intent_data in intent.data:
|
||||
for sentence in intent_data.sentences:
|
||||
_collect_list_references(sentence, wildcard_names)
|
||||
|
||||
for wildcard_name in wildcard_names:
|
||||
intents.slot_lists[wildcard_name] = WildcardSlotList(wildcard_name)
|
||||
|
||||
# Match response text
|
||||
result = recognize(response_text, intents)
|
||||
if result is None:
|
||||
# No match
|
||||
return AssistSatelliteAnswer(id=None, sentence=response_text)
|
||||
|
||||
assert result.intent_metadata
|
||||
return AssistSatelliteAnswer(
|
||||
id=result.intent_metadata["answer_id"],
|
||||
sentence=response_text,
|
||||
slots={
|
||||
entity_name: entity.value
|
||||
for entity_name, entity in result.entities.items()
|
||||
},
|
||||
)
|
||||
|
||||
async def async_accept_pipeline_from_satellite(
|
||||
self,
|
||||
audio_stream: AsyncIterable[bytes],
|
||||
@@ -476,11 +351,6 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
self._internal_on_pipeline_event(PipelineEvent(PipelineEventType.RUN_END))
|
||||
return
|
||||
|
||||
if (self._ask_question_future is not None) and (
|
||||
start_stage == PipelineStage.STT
|
||||
):
|
||||
end_stage = PipelineStage.STT
|
||||
|
||||
device_id = self.registry_entry.device_id if self.registry_entry else None
|
||||
|
||||
# Refresh context if necessary
|
||||
@@ -563,16 +433,6 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
self._set_state(AssistSatelliteState.IDLE)
|
||||
elif event.type is PipelineEventType.STT_START:
|
||||
self._set_state(AssistSatelliteState.LISTENING)
|
||||
elif event.type is PipelineEventType.STT_END:
|
||||
# Intercepting text for ask question
|
||||
if (
|
||||
(self._ask_question_future is not None)
|
||||
and (not self._ask_question_future.done())
|
||||
and event.data
|
||||
):
|
||||
self._ask_question_future.set_result(
|
||||
event.data.get("stt_output", {}).get("text")
|
||||
)
|
||||
elif event.type is PipelineEventType.INTENT_START:
|
||||
self._set_state(AssistSatelliteState.PROCESSING)
|
||||
elif event.type is PipelineEventType.TTS_START:
|
||||
@@ -583,12 +443,6 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
if not self._run_has_tts:
|
||||
self._set_state(AssistSatelliteState.IDLE)
|
||||
|
||||
if (self._ask_question_future is not None) and (
|
||||
not self._ask_question_future.done()
|
||||
):
|
||||
# No text for ask question
|
||||
self._ask_question_future.set_result(None)
|
||||
|
||||
self.on_pipeline_event(event)
|
||||
|
||||
@callback
|
||||
@@ -723,15 +577,3 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
media_id_source=media_id_source,
|
||||
preannounce_media_id=preannounce_media_id,
|
||||
)
|
||||
|
||||
|
||||
def _collect_list_references(expression: Expression, list_names: set[str]) -> None:
|
||||
"""Collect list reference names recursively."""
|
||||
if isinstance(expression, Sequence):
|
||||
seq: Sequence = expression
|
||||
for item in seq.items:
|
||||
_collect_list_references(item, list_names)
|
||||
elif isinstance(expression, ListReference):
|
||||
# {list}
|
||||
list_ref: ListReference = expression
|
||||
list_names.add(list_ref.slot_name)
|
||||
|
||||
@@ -10,9 +10,6 @@
|
||||
},
|
||||
"start_conversation": {
|
||||
"service": "mdi:forum"
|
||||
},
|
||||
"ask_question": {
|
||||
"service": "mdi:microphone-question"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,5 @@
|
||||
"dependencies": ["assist_pipeline", "http", "stt", "tts"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/assist_satellite",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==2.2.3"]
|
||||
"quality_scale": "internal"
|
||||
}
|
||||
|
||||
@@ -54,35 +54,3 @@ start_conversation:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
ask_question:
|
||||
fields:
|
||||
entity_id:
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
domain: assist_satellite
|
||||
supported_features:
|
||||
- assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION
|
||||
question:
|
||||
required: false
|
||||
example: "What kind of music would you like to play?"
|
||||
default: ""
|
||||
selector:
|
||||
text:
|
||||
question_media_id:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
preannounce:
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
preannounce_media_id:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
answers:
|
||||
required: false
|
||||
selector:
|
||||
object:
|
||||
|
||||
@@ -59,36 +59,6 @@
|
||||
"description": "Custom media ID to play before the start message or media."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ask_question": {
|
||||
"name": "Ask question",
|
||||
"description": "Asks a question and gets the user's response.",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"name": "Entity",
|
||||
"description": "Assist satellite entity to ask the question on."
|
||||
},
|
||||
"question": {
|
||||
"name": "Question",
|
||||
"description": "The question to ask."
|
||||
},
|
||||
"question_media_id": {
|
||||
"name": "Question media ID",
|
||||
"description": "The media ID of the question to use instead of text-to-speech."
|
||||
},
|
||||
"preannounce": {
|
||||
"name": "Preannounce",
|
||||
"description": "Play a sound before the start message or media."
|
||||
},
|
||||
"preannounce_media_id": {
|
||||
"name": "Preannounce media ID",
|
||||
"description": "Custom media ID to play before the start message or media."
|
||||
},
|
||||
"answers": {
|
||||
"name": "Answers",
|
||||
"description": "Possible answers to the question."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ DATA_BLUEPRINTS = "automation_blueprints"
|
||||
|
||||
def _blueprint_in_use(hass: HomeAssistant, blueprint_path: str) -> bool:
|
||||
"""Return True if any automation references the blueprint."""
|
||||
from . import automations_with_blueprint # noqa: PLC0415
|
||||
from . import automations_with_blueprint # pylint: disable=import-outside-toplevel
|
||||
|
||||
return len(automations_with_blueprint(hass, blueprint_path)) > 0
|
||||
|
||||
@@ -28,7 +28,8 @@ async def _reload_blueprint_automations(
|
||||
@callback
|
||||
def async_get_blueprints(hass: HomeAssistant) -> blueprint.DomainBlueprints:
|
||||
"""Get automation blueprints."""
|
||||
from .config import AUTOMATION_BLUEPRINT_SCHEMA # noqa: PLC0415
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from .config import AUTOMATION_BLUEPRINT_SCHEMA
|
||||
|
||||
return blueprint.DomainBlueprints(
|
||||
hass,
|
||||
|
||||
@@ -94,10 +94,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
if not with_hassio:
|
||||
reader_writer = CoreBackupReaderWriter(hass)
|
||||
else:
|
||||
# pylint: disable-next=hass-component-root-import
|
||||
from homeassistant.components.hassio.backup import ( # noqa: PLC0415
|
||||
SupervisorBackupReaderWriter,
|
||||
)
|
||||
# pylint: disable-next=import-outside-toplevel, hass-component-root-import
|
||||
from homeassistant.components.hassio.backup import SupervisorBackupReaderWriter
|
||||
|
||||
reader_writer = SupervisorBackupReaderWriter(hass)
|
||||
|
||||
|
||||
@@ -240,10 +240,6 @@ async def _async_get_stream_image(
|
||||
height: int | None = None,
|
||||
wait_for_next_keyframe: bool = False,
|
||||
) -> bytes | None:
|
||||
if (provider := camera._webrtc_provider) and ( # noqa: SLF001
|
||||
image := await provider.async_get_image(camera, width=width, height=height)
|
||||
) is not None:
|
||||
return image
|
||||
if not camera.stream and CameraEntityFeature.STREAM in camera.supported_features:
|
||||
camera.stream = await camera.async_create_stream()
|
||||
if camera.stream:
|
||||
|
||||
@@ -156,15 +156,6 @@ class CameraWebRTCProvider(ABC):
|
||||
"""Close the session."""
|
||||
return ## This is an optional method so we need a default here.
|
||||
|
||||
async def async_get_image(
|
||||
self,
|
||||
camera: Camera,
|
||||
width: int | None = None,
|
||||
height: int | None = None,
|
||||
) -> bytes | None:
|
||||
"""Get an image from the camera."""
|
||||
return None
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_webrtc_provider(
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["acme", "hass_nabucasa", "snitun"],
|
||||
"requirements": ["hass-nabucasa==0.103.0"],
|
||||
"requirements": ["hass-nabucasa==0.101.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -54,10 +54,10 @@ class Control4RuntimeData:
|
||||
type Control4ConfigEntry = ConfigEntry[Control4RuntimeData]
|
||||
|
||||
|
||||
async def call_c4_api_retry(func, *func_args): # noqa: RET503
|
||||
async def call_c4_api_retry(func, *func_args):
|
||||
"""Call C4 API function and retry on failure."""
|
||||
# Ruff doesn't understand this loop - the exception is always raised after the retries
|
||||
for i in range(API_RETRY_TIMES):
|
||||
for i in range(API_RETRY_TIMES): # noqa: RET503
|
||||
try:
|
||||
return await func(*func_args)
|
||||
except client_exceptions.ClientError as exception:
|
||||
|
||||
@@ -271,7 +271,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
)
|
||||
|
||||
# Temporary migration. We can remove this in 2024.10
|
||||
from homeassistant.components.assist_pipeline import ( # noqa: PLC0415
|
||||
from homeassistant.components.assist_pipeline import ( # pylint: disable=import-outside-toplevel
|
||||
async_migrate_engine,
|
||||
)
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ from homeassistant.core import Event, HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
|
||||
from .const import DOMAIN, GATEWAY_SERIAL_PATTERN, PLATFORMS
|
||||
from .const import GATEWAY_SERIAL_PATTERN, PLATFORMS
|
||||
|
||||
type DevoloHomeControlConfigEntry = ConfigEntry[list[HomeControl]]
|
||||
|
||||
@@ -32,16 +32,10 @@ async def async_setup_entry(
|
||||
credentials_valid = await hass.async_add_executor_job(mydevolo.credentials_valid)
|
||||
|
||||
if not credentials_valid:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
)
|
||||
raise ConfigEntryAuthFailed
|
||||
|
||||
if await hass.async_add_executor_job(mydevolo.maintenance):
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="maintenance",
|
||||
)
|
||||
raise ConfigEntryNotReady
|
||||
|
||||
gateway_ids = await hass.async_add_executor_job(mydevolo.get_gateway_ids)
|
||||
|
||||
@@ -75,11 +69,7 @@ async def async_setup_entry(
|
||||
)
|
||||
)
|
||||
except GatewayOfflineError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="connection_failed",
|
||||
translation_placeholders={"gateway_id": gateway_id},
|
||||
) from err
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
@@ -101,9 +91,7 @@ async def async_unload_entry(
|
||||
|
||||
|
||||
async def async_remove_config_entry_device(
|
||||
hass: HomeAssistant,
|
||||
config_entry: DevoloHomeControlConfigEntry,
|
||||
device_entry: DeviceEntry,
|
||||
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry
|
||||
) -> bool:
|
||||
"""Remove a config entry from a device."""
|
||||
return True
|
||||
|
||||
@@ -45,16 +45,5 @@
|
||||
"name": "Brightness"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"connection_failed": {
|
||||
"message": "Failed to connect to devolo Home Control central unit {gateway_id}."
|
||||
},
|
||||
"invalid_auth": {
|
||||
"message": "Authentication failed. Please re-authenticaticate with your mydevolo account."
|
||||
},
|
||||
"maintenance": {
|
||||
"message": "devolo Home Control is currently in maintenance mode."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +108,8 @@ def download_file(service: ServiceCall) -> None:
|
||||
_LOGGER.debug("%s -> %s", url, final_path)
|
||||
|
||||
with open(final_path, "wb") as fil:
|
||||
fil.writelines(req.iter_content(1024))
|
||||
for chunk in req.iter_content(1024):
|
||||
fil.write(chunk)
|
||||
|
||||
_LOGGER.debug("Downloading of %s done", url)
|
||||
service.hass.bus.fire(
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Generic
|
||||
|
||||
from deebot_client.capabilities import CapabilityEvent
|
||||
from deebot_client.events.base import Event
|
||||
from deebot_client.events.water_info import MopAttachedEvent
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
@@ -16,14 +16,15 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import EcovacsConfigEntry
|
||||
from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity
|
||||
from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EventT
|
||||
from .util import get_supported_entities
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class EcovacsBinarySensorEntityDescription[EventT: Event](
|
||||
class EcovacsBinarySensorEntityDescription(
|
||||
BinarySensorEntityDescription,
|
||||
EcovacsCapabilityEntityDescription,
|
||||
Generic[EventT],
|
||||
):
|
||||
"""Class describing Deebot binary sensor entity."""
|
||||
|
||||
@@ -54,7 +55,7 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
class EcovacsBinarySensor[EventT: Event](
|
||||
class EcovacsBinarySensor(
|
||||
EcovacsDescriptionEntity[CapabilityEvent[EventT]],
|
||||
BinarySensorEntity,
|
||||
):
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
from deebot_client.capabilities import Capabilities
|
||||
from deebot_client.device import Device
|
||||
@@ -18,8 +18,11 @@ from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
CapabilityEntity = TypeVar("CapabilityEntity")
|
||||
EventT = TypeVar("EventT", bound=Event)
|
||||
|
||||
class EcovacsEntity[CapabilityEntityT](Entity):
|
||||
|
||||
class EcovacsEntity(Entity, Generic[CapabilityEntity]):
|
||||
"""Ecovacs entity."""
|
||||
|
||||
_attr_should_poll = False
|
||||
@@ -29,7 +32,7 @@ class EcovacsEntity[CapabilityEntityT](Entity):
|
||||
def __init__(
|
||||
self,
|
||||
device: Device,
|
||||
capability: CapabilityEntityT,
|
||||
capability: CapabilityEntity,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize entity."""
|
||||
@@ -77,7 +80,7 @@ class EcovacsEntity[CapabilityEntityT](Entity):
|
||||
|
||||
self._subscribe(AvailabilityEvent, on_available)
|
||||
|
||||
def _subscribe[EventT: Event](
|
||||
def _subscribe(
|
||||
self,
|
||||
event_type: type[EventT],
|
||||
callback: Callable[[EventT], Coroutine[Any, Any, None]],
|
||||
@@ -95,13 +98,13 @@ class EcovacsEntity[CapabilityEntityT](Entity):
|
||||
self._device.events.request_refresh(event_type)
|
||||
|
||||
|
||||
class EcovacsDescriptionEntity[CapabilityEntityT](EcovacsEntity[CapabilityEntityT]):
|
||||
class EcovacsDescriptionEntity(EcovacsEntity[CapabilityEntity]):
|
||||
"""Ecovacs entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: Device,
|
||||
capability: CapabilityEntityT,
|
||||
capability: CapabilityEntity,
|
||||
entity_description: EntityDescription,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
@@ -111,12 +114,13 @@ class EcovacsDescriptionEntity[CapabilityEntityT](EcovacsEntity[CapabilityEntity
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class EcovacsCapabilityEntityDescription[CapabilityEntityT](
|
||||
class EcovacsCapabilityEntityDescription(
|
||||
EntityDescription,
|
||||
Generic[CapabilityEntity],
|
||||
):
|
||||
"""Ecovacs entity description."""
|
||||
|
||||
capability_fn: Callable[[Capabilities], CapabilityEntityT | None]
|
||||
capability_fn: Callable[[Capabilities], CapabilityEntity | None]
|
||||
|
||||
|
||||
class EcovacsLegacyEntity(Entity):
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==13.4.0"]
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==13.3.0"]
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Generic
|
||||
|
||||
from deebot_client.capabilities import CapabilitySet
|
||||
from deebot_client.events import CleanCountEvent, CutDirectionEvent, VolumeEvent
|
||||
from deebot_client.events.base import Event
|
||||
|
||||
from homeassistant.components.number import (
|
||||
NumberEntity,
|
||||
@@ -23,14 +23,16 @@ from .entity import (
|
||||
EcovacsCapabilityEntityDescription,
|
||||
EcovacsDescriptionEntity,
|
||||
EcovacsEntity,
|
||||
EventT,
|
||||
)
|
||||
from .util import get_supported_entities
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class EcovacsNumberEntityDescription[EventT: Event](
|
||||
class EcovacsNumberEntityDescription(
|
||||
NumberEntityDescription,
|
||||
EcovacsCapabilityEntityDescription,
|
||||
Generic[EventT],
|
||||
):
|
||||
"""Ecovacs number entity description."""
|
||||
|
||||
@@ -92,7 +94,7 @@ async def async_setup_entry(
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class EcovacsNumberEntity[EventT: Event](
|
||||
class EcovacsNumberEntity(
|
||||
EcovacsDescriptionEntity[CapabilitySet[EventT, [int]]],
|
||||
NumberEntity,
|
||||
):
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from typing import Any, Generic
|
||||
|
||||
from deebot_client.capabilities import CapabilitySetTypes
|
||||
from deebot_client.device import Device
|
||||
from deebot_client.events import WorkModeEvent
|
||||
from deebot_client.events.base import Event
|
||||
from deebot_client.events.water_info import WaterAmountEvent
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
@@ -16,14 +15,15 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import EcovacsConfigEntry
|
||||
from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity
|
||||
from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EventT
|
||||
from .util import get_name_key, get_supported_entities
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class EcovacsSelectEntityDescription[EventT: Event](
|
||||
class EcovacsSelectEntityDescription(
|
||||
SelectEntityDescription,
|
||||
EcovacsCapabilityEntityDescription,
|
||||
Generic[EventT],
|
||||
):
|
||||
"""Ecovacs select entity description."""
|
||||
|
||||
@@ -66,7 +66,7 @@ async def async_setup_entry(
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class EcovacsSelectEntity[EventT: Event](
|
||||
class EcovacsSelectEntity(
|
||||
EcovacsDescriptionEntity[CapabilitySetTypes[EventT, [str], str]],
|
||||
SelectEntity,
|
||||
):
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from typing import Any, Generic
|
||||
|
||||
from deebot_client.capabilities import CapabilityEvent, CapabilityLifeSpan, DeviceType
|
||||
from deebot_client.device import Device
|
||||
@@ -46,14 +46,16 @@ from .entity import (
|
||||
EcovacsDescriptionEntity,
|
||||
EcovacsEntity,
|
||||
EcovacsLegacyEntity,
|
||||
EventT,
|
||||
)
|
||||
from .util import get_name_key, get_options, get_supported_entities
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class EcovacsSensorEntityDescription[EventT: Event](
|
||||
class EcovacsSensorEntityDescription(
|
||||
EcovacsCapabilityEntityDescription,
|
||||
SensorEntityDescription,
|
||||
Generic[EventT],
|
||||
):
|
||||
"""Ecovacs sensor entity description."""
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, override
|
||||
from typing import Generic, TypeVar, override
|
||||
|
||||
from eheimdigital.classic_vario import EheimDigitalClassicVario
|
||||
from eheimdigital.device import EheimDigitalDevice
|
||||
@@ -30,16 +30,16 @@ from .entity import EheimDigitalEntity, exception_handler
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class EheimDigitalNumberDescription[_DeviceT: EheimDigitalDevice](
|
||||
NumberEntityDescription
|
||||
):
|
||||
class EheimDigitalNumberDescription(NumberEntityDescription, Generic[_DeviceT_co]):
|
||||
"""Class describing EHEIM Digital sensor entities."""
|
||||
|
||||
value_fn: Callable[[_DeviceT], float | None]
|
||||
set_value_fn: Callable[[_DeviceT, float], Awaitable[None]]
|
||||
uom_fn: Callable[[_DeviceT], str] | None = None
|
||||
value_fn: Callable[[_DeviceT_co], float | None]
|
||||
set_value_fn: Callable[[_DeviceT_co, float], Awaitable[None]]
|
||||
uom_fn: Callable[[_DeviceT_co], str] | None = None
|
||||
|
||||
|
||||
CLASSICVARIO_DESCRIPTIONS: tuple[
|
||||
@@ -136,7 +136,7 @@ async def async_setup_entry(
|
||||
device_address: dict[str, EheimDigitalDevice],
|
||||
) -> None:
|
||||
"""Set up the number entities for one or multiple devices."""
|
||||
entities: list[EheimDigitalNumber[Any]] = []
|
||||
entities: list[EheimDigitalNumber[EheimDigitalDevice]] = []
|
||||
for device in device_address.values():
|
||||
if isinstance(device, EheimDigitalClassicVario):
|
||||
entities.extend(
|
||||
@@ -163,18 +163,18 @@ async def async_setup_entry(
|
||||
async_setup_device_entities(coordinator.hub.devices)
|
||||
|
||||
|
||||
class EheimDigitalNumber[_DeviceT: EheimDigitalDevice](
|
||||
EheimDigitalEntity[_DeviceT], NumberEntity
|
||||
class EheimDigitalNumber(
|
||||
EheimDigitalEntity[_DeviceT_co], NumberEntity, Generic[_DeviceT_co]
|
||||
):
|
||||
"""Represent a EHEIM Digital number entity."""
|
||||
|
||||
entity_description: EheimDigitalNumberDescription[_DeviceT]
|
||||
entity_description: EheimDigitalNumberDescription[_DeviceT_co]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: EheimDigitalUpdateCoordinator,
|
||||
device: _DeviceT,
|
||||
description: EheimDigitalNumberDescription[_DeviceT],
|
||||
device: _DeviceT_co,
|
||||
description: EheimDigitalNumberDescription[_DeviceT_co],
|
||||
) -> None:
|
||||
"""Initialize an EHEIM Digital number entity."""
|
||||
super().__init__(coordinator, device)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, override
|
||||
from typing import Generic, TypeVar, override
|
||||
|
||||
from eheimdigital.classic_vario import EheimDigitalClassicVario
|
||||
from eheimdigital.device import EheimDigitalDevice
|
||||
@@ -17,15 +17,15 @@ from .entity import EheimDigitalEntity, exception_handler
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class EheimDigitalSelectDescription[_DeviceT: EheimDigitalDevice](
|
||||
SelectEntityDescription
|
||||
):
|
||||
class EheimDigitalSelectDescription(SelectEntityDescription, Generic[_DeviceT_co]):
|
||||
"""Class describing EHEIM Digital select entities."""
|
||||
|
||||
value_fn: Callable[[_DeviceT], str | None]
|
||||
set_value_fn: Callable[[_DeviceT, str], Awaitable[None]]
|
||||
value_fn: Callable[[_DeviceT_co], str | None]
|
||||
set_value_fn: Callable[[_DeviceT_co, str], Awaitable[None]]
|
||||
|
||||
|
||||
CLASSICVARIO_DESCRIPTIONS: tuple[
|
||||
@@ -59,7 +59,7 @@ async def async_setup_entry(
|
||||
device_address: dict[str, EheimDigitalDevice],
|
||||
) -> None:
|
||||
"""Set up the number entities for one or multiple devices."""
|
||||
entities: list[EheimDigitalSelect[Any]] = []
|
||||
entities: list[EheimDigitalSelect[EheimDigitalDevice]] = []
|
||||
for device in device_address.values():
|
||||
if isinstance(device, EheimDigitalClassicVario):
|
||||
entities.extend(
|
||||
@@ -75,18 +75,18 @@ async def async_setup_entry(
|
||||
async_setup_device_entities(coordinator.hub.devices)
|
||||
|
||||
|
||||
class EheimDigitalSelect[_DeviceT: EheimDigitalDevice](
|
||||
EheimDigitalEntity[_DeviceT], SelectEntity
|
||||
class EheimDigitalSelect(
|
||||
EheimDigitalEntity[_DeviceT_co], SelectEntity, Generic[_DeviceT_co]
|
||||
):
|
||||
"""Represent an EHEIM Digital select entity."""
|
||||
|
||||
entity_description: EheimDigitalSelectDescription[_DeviceT]
|
||||
entity_description: EheimDigitalSelectDescription[_DeviceT_co]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: EheimDigitalUpdateCoordinator,
|
||||
device: _DeviceT,
|
||||
description: EheimDigitalSelectDescription[_DeviceT],
|
||||
device: _DeviceT_co,
|
||||
description: EheimDigitalSelectDescription[_DeviceT_co],
|
||||
) -> None:
|
||||
"""Initialize an EHEIM Digital select entity."""
|
||||
super().__init__(coordinator, device)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, override
|
||||
from typing import Generic, TypeVar, override
|
||||
|
||||
from eheimdigital.classic_vario import EheimDigitalClassicVario
|
||||
from eheimdigital.device import EheimDigitalDevice
|
||||
@@ -20,14 +20,14 @@ from .entity import EheimDigitalEntity
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class EheimDigitalSensorDescription[_DeviceT: EheimDigitalDevice](
|
||||
SensorEntityDescription
|
||||
):
|
||||
class EheimDigitalSensorDescription(SensorEntityDescription, Generic[_DeviceT_co]):
|
||||
"""Class describing EHEIM Digital sensor entities."""
|
||||
|
||||
value_fn: Callable[[_DeviceT], float | str | None]
|
||||
value_fn: Callable[[_DeviceT_co], float | str | None]
|
||||
|
||||
|
||||
CLASSICVARIO_DESCRIPTIONS: tuple[
|
||||
@@ -75,7 +75,7 @@ async def async_setup_entry(
|
||||
device_address: dict[str, EheimDigitalDevice],
|
||||
) -> None:
|
||||
"""Set up the light entities for one or multiple devices."""
|
||||
entities: list[EheimDigitalSensor[Any]] = []
|
||||
entities: list[EheimDigitalSensor[EheimDigitalDevice]] = []
|
||||
for device in device_address.values():
|
||||
if isinstance(device, EheimDigitalClassicVario):
|
||||
entities += [
|
||||
@@ -91,18 +91,18 @@ async def async_setup_entry(
|
||||
async_setup_device_entities(coordinator.hub.devices)
|
||||
|
||||
|
||||
class EheimDigitalSensor[_DeviceT: EheimDigitalDevice](
|
||||
EheimDigitalEntity[_DeviceT], SensorEntity
|
||||
class EheimDigitalSensor(
|
||||
EheimDigitalEntity[_DeviceT_co], SensorEntity, Generic[_DeviceT_co]
|
||||
):
|
||||
"""Represent a EHEIM Digital sensor entity."""
|
||||
|
||||
entity_description: EheimDigitalSensorDescription[_DeviceT]
|
||||
entity_description: EheimDigitalSensorDescription[_DeviceT_co]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: EheimDigitalUpdateCoordinator,
|
||||
device: _DeviceT,
|
||||
description: EheimDigitalSensorDescription[_DeviceT],
|
||||
device: _DeviceT_co,
|
||||
description: EheimDigitalSensorDescription[_DeviceT_co],
|
||||
) -> None:
|
||||
"""Initialize an EHEIM Digital number entity."""
|
||||
super().__init__(coordinator, device)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import time
|
||||
from typing import Any, final, override
|
||||
from typing import Generic, TypeVar, final, override
|
||||
|
||||
from eheimdigital.classic_vario import EheimDigitalClassicVario
|
||||
from eheimdigital.device import EheimDigitalDevice
|
||||
@@ -19,13 +19,15 @@ from .entity import EheimDigitalEntity, exception_handler
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class EheimDigitalTimeDescription[_DeviceT: EheimDigitalDevice](TimeEntityDescription):
|
||||
class EheimDigitalTimeDescription(TimeEntityDescription, Generic[_DeviceT_co]):
|
||||
"""Class describing EHEIM Digital time entities."""
|
||||
|
||||
value_fn: Callable[[_DeviceT], time | None]
|
||||
set_value_fn: Callable[[_DeviceT, time], Awaitable[None]]
|
||||
value_fn: Callable[[_DeviceT_co], time | None]
|
||||
set_value_fn: Callable[[_DeviceT_co, time], Awaitable[None]]
|
||||
|
||||
|
||||
CLASSICVARIO_DESCRIPTIONS: tuple[
|
||||
@@ -77,7 +79,7 @@ async def async_setup_entry(
|
||||
device_address: dict[str, EheimDigitalDevice],
|
||||
) -> None:
|
||||
"""Set up the time entities for one or multiple devices."""
|
||||
entities: list[EheimDigitalTime[Any]] = []
|
||||
entities: list[EheimDigitalTime[EheimDigitalDevice]] = []
|
||||
for device in device_address.values():
|
||||
if isinstance(device, EheimDigitalClassicVario):
|
||||
entities.extend(
|
||||
@@ -101,18 +103,18 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
@final
|
||||
class EheimDigitalTime[_DeviceT: EheimDigitalDevice](
|
||||
EheimDigitalEntity[_DeviceT], TimeEntity
|
||||
class EheimDigitalTime(
|
||||
EheimDigitalEntity[_DeviceT_co], TimeEntity, Generic[_DeviceT_co]
|
||||
):
|
||||
"""Represent an EHEIM Digital time entity."""
|
||||
|
||||
entity_description: EheimDigitalTimeDescription[_DeviceT]
|
||||
entity_description: EheimDigitalTimeDescription[_DeviceT_co]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: EheimDigitalUpdateCoordinator,
|
||||
device: _DeviceT,
|
||||
description: EheimDigitalTimeDescription[_DeviceT],
|
||||
device: _DeviceT_co,
|
||||
description: EheimDigitalTimeDescription[_DeviceT_co],
|
||||
) -> None:
|
||||
"""Initialize an EHEIM Digital time entity."""
|
||||
super().__init__(coordinator, device)
|
||||
|
||||
@@ -60,7 +60,6 @@ _VOICE_ASSISTANT_EVENT_TYPES: EsphomeEnumMapper[
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_STT_START: PipelineEventType.STT_START,
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_STT_END: PipelineEventType.STT_END,
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_START: PipelineEventType.INTENT_START,
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS: PipelineEventType.INTENT_PROGRESS,
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: PipelineEventType.INTENT_END,
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: PipelineEventType.TTS_START,
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: PipelineEventType.TTS_END,
|
||||
@@ -283,12 +282,6 @@ class EsphomeAssistSatellite(
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END:
|
||||
assert event.data is not None
|
||||
data_to_send = {"text": event.data["stt_output"]["text"]}
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS:
|
||||
data_to_send = {
|
||||
"tts_start_streaming": "1"
|
||||
if (event.data and event.data.get("tts_start_streaming"))
|
||||
else "0",
|
||||
}
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END:
|
||||
assert event.data is not None
|
||||
data_to_send = {
|
||||
@@ -339,7 +332,7 @@ class EsphomeAssistSatellite(
|
||||
}
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START:
|
||||
assert event.data is not None
|
||||
if tts_output := event.data.get("tts_output"):
|
||||
if tts_output := event.data["tts_output"]:
|
||||
path = tts_output["url"]
|
||||
url = async_process_play_media_url(self.hass, path)
|
||||
data_to_send = {"url": url}
|
||||
|
||||
@@ -63,7 +63,9 @@ class ESPHomeDashboardManager:
|
||||
if not (data := self._data) or not (info := data.get("info")):
|
||||
return
|
||||
if is_hassio(self._hass):
|
||||
from homeassistant.components.hassio import get_addons_info # noqa: PLC0415
|
||||
from homeassistant.components.hassio import ( # pylint: disable=import-outside-toplevel
|
||||
get_addons_info,
|
||||
)
|
||||
|
||||
if (addons := get_addons_info(self._hass)) is not None and info[
|
||||
"addon_slug"
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==32.2.4",
|
||||
"aioesphomeapi==32.2.1",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==2.16.0"
|
||||
],
|
||||
|
||||
@@ -364,7 +364,8 @@ def _frontend_root(dev_repo_path: str | None) -> pathlib.Path:
|
||||
if dev_repo_path is not None:
|
||||
return pathlib.Path(dev_repo_path) / "hass_frontend"
|
||||
# Keep import here so that we can import frontend without installing reqs
|
||||
import hass_frontend # noqa: PLC0415
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
import hass_frontend
|
||||
|
||||
return hass_frontend.where()
|
||||
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
"""The go2rtc component."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError
|
||||
from awesomeversion import AwesomeVersion
|
||||
from go2rtc_client import Go2RtcRestClient
|
||||
@@ -35,7 +32,7 @@ from homeassistant.components.default_config import DOMAIN as DEFAULT_CONFIG_DOM
|
||||
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
|
||||
from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
discovery_flow,
|
||||
@@ -101,7 +98,6 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
|
||||
_DATA_GO2RTC: HassKey[str] = HassKey(DOMAIN)
|
||||
_RETRYABLE_ERRORS = (ClientConnectionError, ServerConnectionError)
|
||||
type Go2RtcConfigEntry = ConfigEntry[WebRTCProvider]
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
@@ -155,14 +151,13 @@ async def _remove_go2rtc_entries(hass: HomeAssistant) -> None:
|
||||
await hass.config_entries.async_remove(entry.entry_id)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: Go2RtcConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up go2rtc from a config entry."""
|
||||
|
||||
url = hass.data[_DATA_GO2RTC]
|
||||
session = async_get_clientsession(hass)
|
||||
client = Go2RtcRestClient(session, url)
|
||||
|
||||
# Validate the server URL
|
||||
try:
|
||||
client = Go2RtcRestClient(async_get_clientsession(hass), url)
|
||||
version = await client.validate_server_version()
|
||||
if version < AwesomeVersion(RECOMMENDED_VERSION):
|
||||
ir.async_create_issue(
|
||||
@@ -193,14 +188,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: Go2RtcConfigEntry) -> bo
|
||||
_LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err)
|
||||
return False
|
||||
|
||||
provider = entry.runtime_data = WebRTCProvider(hass, url, session, client)
|
||||
entry.async_on_unload(async_register_webrtc_provider(hass, provider))
|
||||
provider = WebRTCProvider(hass, url)
|
||||
async_register_webrtc_provider(hass, provider)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: Go2RtcConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a go2rtc config entry."""
|
||||
await entry.runtime_data.teardown()
|
||||
return True
|
||||
|
||||
|
||||
@@ -212,18 +206,12 @@ async def _get_binary(hass: HomeAssistant) -> str | None:
|
||||
class WebRTCProvider(CameraWebRTCProvider):
|
||||
"""WebRTC provider."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
url: str,
|
||||
session: ClientSession,
|
||||
rest_client: Go2RtcRestClient,
|
||||
) -> None:
|
||||
def __init__(self, hass: HomeAssistant, url: str) -> None:
|
||||
"""Initialize the WebRTC provider."""
|
||||
self._hass = hass
|
||||
self._url = url
|
||||
self._session = session
|
||||
self._rest_client = rest_client
|
||||
self._session = async_get_clientsession(hass)
|
||||
self._rest_client = Go2RtcRestClient(self._session, url)
|
||||
self._sessions: dict[str, Go2RtcWsClient] = {}
|
||||
|
||||
@property
|
||||
@@ -244,16 +232,32 @@ class WebRTCProvider(CameraWebRTCProvider):
|
||||
send_message: WebRTCSendMessage,
|
||||
) -> None:
|
||||
"""Handle the WebRTC offer and return the answer via the provided callback."""
|
||||
try:
|
||||
await self._update_stream_source(camera)
|
||||
except HomeAssistantError as err:
|
||||
send_message(WebRTCError("go2rtc_webrtc_offer_failed", str(err)))
|
||||
return
|
||||
|
||||
self._sessions[session_id] = ws_client = Go2RtcWsClient(
|
||||
self._session, self._url, source=camera.entity_id
|
||||
)
|
||||
|
||||
if not (stream_source := await camera.stream_source()):
|
||||
send_message(
|
||||
WebRTCError("go2rtc_webrtc_offer_failed", "Camera has no stream source")
|
||||
)
|
||||
return
|
||||
|
||||
streams = await self._rest_client.streams.list()
|
||||
|
||||
if (stream := streams.get(camera.entity_id)) is None or not any(
|
||||
stream_source == producer.url for producer in stream.producers
|
||||
):
|
||||
await self._rest_client.streams.add(
|
||||
camera.entity_id,
|
||||
[
|
||||
stream_source,
|
||||
# We are setting any ffmpeg rtsp related logs to debug
|
||||
# Connection problems to the camera will be logged by the first stream
|
||||
# Therefore setting it to debug will not hide any important logs
|
||||
f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug",
|
||||
],
|
||||
)
|
||||
|
||||
@callback
|
||||
def on_messages(message: ReceiveMessages) -> None:
|
||||
"""Handle messages."""
|
||||
@@ -287,48 +291,3 @@ class WebRTCProvider(CameraWebRTCProvider):
|
||||
"""Close the session."""
|
||||
ws_client = self._sessions.pop(session_id)
|
||||
self._hass.async_create_task(ws_client.close())
|
||||
|
||||
async def async_get_image(
|
||||
self,
|
||||
camera: Camera,
|
||||
width: int | None = None,
|
||||
height: int | None = None,
|
||||
) -> bytes | None:
|
||||
"""Get an image from the camera."""
|
||||
await self._update_stream_source(camera)
|
||||
return await self._rest_client.get_jpeg_snapshot(
|
||||
camera.entity_id, width, height
|
||||
)
|
||||
|
||||
async def _update_stream_source(self, camera: Camera) -> None:
|
||||
"""Update the stream source in go2rtc config if needed."""
|
||||
if not (stream_source := await camera.stream_source()):
|
||||
await self.teardown()
|
||||
raise HomeAssistantError("Camera has no stream source")
|
||||
|
||||
if not self.async_is_supported(stream_source):
|
||||
await self.teardown()
|
||||
raise HomeAssistantError("Stream source is not supported by go2rtc")
|
||||
|
||||
streams = await self._rest_client.streams.list()
|
||||
|
||||
if (stream := streams.get(camera.entity_id)) is None or not any(
|
||||
stream_source == producer.url for producer in stream.producers
|
||||
):
|
||||
await self._rest_client.streams.add(
|
||||
camera.entity_id,
|
||||
[
|
||||
stream_source,
|
||||
# We are setting any ffmpeg rtsp related logs to debug
|
||||
# Connection problems to the camera will be logged by the first stream
|
||||
# Therefore setting it to debug will not hide any important logs
|
||||
f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug",
|
||||
f"ffmpeg:{camera.entity_id}#video=mjpeg",
|
||||
],
|
||||
)
|
||||
|
||||
async def teardown(self) -> None:
|
||||
"""Tear down the provider."""
|
||||
for ws_client in self._sessions.values():
|
||||
await ws_client.close()
|
||||
self._sessions.clear()
|
||||
|
||||
@@ -212,7 +212,8 @@ class AbstractConfig(ABC):
|
||||
def async_enable_report_state(self) -> None:
|
||||
"""Enable proactive mode."""
|
||||
# Circular dep
|
||||
from .report_state import async_enable_report_state # noqa: PLC0415
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from .report_state import async_enable_report_state
|
||||
|
||||
if self._unsub_report_state is None:
|
||||
self._unsub_report_state = async_enable_report_state(self.hass, self)
|
||||
@@ -394,7 +395,8 @@ class AbstractConfig(ABC):
|
||||
async def _handle_local_webhook(self, hass, webhook_id, request):
|
||||
"""Handle an incoming local SDK message."""
|
||||
# Circular dep
|
||||
from . import smart_home # noqa: PLC0415
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from . import smart_home
|
||||
|
||||
self._local_last_active = utcnow()
|
||||
|
||||
@@ -653,9 +655,8 @@ class GoogleEntity:
|
||||
if "matter" in self.hass.config.components and any(
|
||||
x for x in device_entry.identifiers if x[0] == "matter"
|
||||
):
|
||||
from homeassistant.components.matter import ( # noqa: PLC0415
|
||||
get_matter_device_info,
|
||||
)
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from homeassistant.components.matter import get_matter_device_info
|
||||
|
||||
# Import matter can block the event loop for multiple seconds
|
||||
# so we import it here to avoid blocking the event loop during
|
||||
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
|
||||
from google.genai import Client
|
||||
from google.genai.errors import APIError, ClientError
|
||||
@@ -13,7 +12,7 @@ from google.genai.types import File, FileState
|
||||
from requests.exceptions import Timeout
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_KEY, Platform
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
@@ -27,19 +26,16 @@ from homeassistant.exceptions import (
|
||||
ConfigEntryNotReady,
|
||||
HomeAssistantError,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
CONF_PROMPT,
|
||||
DEFAULT_AI_TASK_NAME,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
DOMAIN,
|
||||
FILE_POLLING_INTERVAL_SECONDS,
|
||||
LOGGER,
|
||||
RECOMMENDED_AI_TASK_OPTIONS,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
TIMEOUT_MILLIS,
|
||||
)
|
||||
@@ -50,7 +46,6 @@ CONF_FILENAMES = "filenames"
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
PLATFORMS = (
|
||||
Platform.AI_TASK,
|
||||
Platform.CONVERSATION,
|
||||
Platform.TTS,
|
||||
)
|
||||
@@ -214,51 +209,3 @@ async def async_unload_entry(
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_migrate_entry(
|
||||
hass: HomeAssistant, entry: GoogleGenerativeAIConfigEntry
|
||||
) -> bool:
|
||||
"""Migrate old entry."""
|
||||
if entry.version == 1:
|
||||
# Migrate from version 1 to version 2
|
||||
# Move conversation-specific options to a subentry
|
||||
conversation_subentry = ConfigSubentry(
|
||||
data=entry.options,
|
||||
subentry_type="conversation",
|
||||
title=DEFAULT_CONVERSATION_NAME,
|
||||
unique_id=None,
|
||||
)
|
||||
hass.config_entries.async_add_subentry(
|
||||
entry,
|
||||
conversation_subentry,
|
||||
)
|
||||
hass.config_entries.async_add_subentry(
|
||||
entry,
|
||||
ConfigSubentry(
|
||||
data=MappingProxyType(RECOMMENDED_AI_TASK_OPTIONS),
|
||||
subentry_type="ai_task",
|
||||
title=DEFAULT_AI_TASK_NAME,
|
||||
unique_id=None,
|
||||
),
|
||||
)
|
||||
|
||||
# Migrate conversation entity to be linked to subentry
|
||||
ent_reg = er.async_get(hass)
|
||||
for entity_entry in er.async_entries_for_config_entry(ent_reg, entry.entry_id):
|
||||
if entity_entry.domain == Platform.CONVERSATION:
|
||||
ent_reg.async_update_entity(
|
||||
entity_entry.entity_id,
|
||||
config_subentry_id=conversation_subentry.subentry_id,
|
||||
new_unique_id=conversation_subentry.subentry_id,
|
||||
)
|
||||
break
|
||||
|
||||
# Remove options from the main entry
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
options={},
|
||||
version=2,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
"""LLM Task integration for Google Generative AI Conversation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components import ai_task, conversation
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import LOGGER
|
||||
from .entity import ERROR_GETTING_RESPONSE, GoogleGenerativeAILLMBaseEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up LLM Task entities."""
|
||||
entities = [
|
||||
GoogleGenerativeAILLMTaskEntity(config_entry, subentry)
|
||||
for subentry in config_entry.subentries.values()
|
||||
if subentry.subentry_type == "ai_task"
|
||||
]
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class GoogleGenerativeAILLMTaskEntity(
|
||||
ai_task.AITaskEntity,
|
||||
GoogleGenerativeAILLMBaseEntity,
|
||||
):
|
||||
"""Google Generative AI AI Task entity."""
|
||||
|
||||
_attr_supported_features = ai_task.AITaskEntityFeature.GENERATE_TEXT
|
||||
|
||||
async def _async_generate_text(
|
||||
self,
|
||||
task: ai_task.GenTextTask,
|
||||
chat_log: conversation.ChatLog,
|
||||
) -> ai_task.GenTextTaskResult:
|
||||
"""Handle a generate text task."""
|
||||
await self._async_handle_chat_log(chat_log)
|
||||
|
||||
if not isinstance(chat_log.content[-1], conversation.AssistantContent):
|
||||
LOGGER.error(
|
||||
"Last content in chat log is not an AssistantContent: %s. This could be due to the model not returning a valid response",
|
||||
chat_log.content[-1],
|
||||
)
|
||||
raise HomeAssistantError(ERROR_GETTING_RESPONSE)
|
||||
|
||||
return ai_task.GenTextTaskResult(
|
||||
conversation_id=chat_log.conversation_id,
|
||||
text=chat_log.content[-1].content or "",
|
||||
)
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
|
||||
from google import genai
|
||||
@@ -16,11 +17,10 @@ from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
ConfigSubentryFlow,
|
||||
SubentryFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import llm
|
||||
from homeassistant.helpers.selector import (
|
||||
NumberSelector,
|
||||
@@ -45,12 +45,8 @@ from .const import (
|
||||
CONF_TOP_K,
|
||||
CONF_TOP_P,
|
||||
CONF_USE_GOOGLE_SEARCH_TOOL,
|
||||
DEFAULT_AI_TASK_NAME,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
DOMAIN,
|
||||
RECOMMENDED_AI_TASK_OPTIONS,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
RECOMMENDED_CONVERSATION_OPTIONS,
|
||||
RECOMMENDED_HARM_BLOCK_THRESHOLD,
|
||||
RECOMMENDED_MAX_TOKENS,
|
||||
RECOMMENDED_TEMPERATURE,
|
||||
@@ -68,6 +64,12 @@ STEP_API_DATA_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
RECOMMENDED_OPTIONS = {
|
||||
CONF_RECOMMENDED: True,
|
||||
CONF_LLM_HASS_API: llm.LLM_API_ASSIST,
|
||||
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
|
||||
}
|
||||
|
||||
|
||||
async def validate_input(data: dict[str, Any]) -> None:
|
||||
"""Validate the user input allows us to connect.
|
||||
@@ -88,7 +90,7 @@ async def validate_input(data: dict[str, Any]) -> None:
|
||||
class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Google Generative AI Conversation."""
|
||||
|
||||
VERSION = 2
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_api(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -115,20 +117,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_create_entry(
|
||||
title="Google Generative AI",
|
||||
data=user_input,
|
||||
subentries=[
|
||||
{
|
||||
"subentry_type": "conversation",
|
||||
"data": RECOMMENDED_CONVERSATION_OPTIONS,
|
||||
"title": DEFAULT_CONVERSATION_NAME,
|
||||
"unique_id": None,
|
||||
},
|
||||
{
|
||||
"subentry_type": "ai_task",
|
||||
"data": RECOMMENDED_AI_TASK_OPTIONS,
|
||||
"title": DEFAULT_AI_TASK_NAME,
|
||||
"unique_id": None,
|
||||
},
|
||||
],
|
||||
options=RECOMMENDED_OPTIONS,
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="api",
|
||||
@@ -167,97 +156,58 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@callback
|
||||
def async_get_supported_subentry_types(
|
||||
cls, config_entry: ConfigEntry
|
||||
) -> dict[str, type[ConfigSubentryFlow]]:
|
||||
"""Return subentries supported by this integration."""
|
||||
return {
|
||||
"conversation": LLMSubentryFlowHandler,
|
||||
"ai_task": LLMSubentryFlowHandler,
|
||||
}
|
||||
@staticmethod
|
||||
def async_get_options_flow(
|
||||
config_entry: ConfigEntry,
|
||||
) -> OptionsFlow:
|
||||
"""Create the options flow."""
|
||||
return GoogleGenerativeAIOptionsFlow(config_entry)
|
||||
|
||||
|
||||
class LLMSubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Flow for managing conversation subentries."""
|
||||
class GoogleGenerativeAIOptionsFlow(OptionsFlow):
|
||||
"""Google Generative AI config flow options handler."""
|
||||
|
||||
last_rendered_recommended = False
|
||||
is_new: bool
|
||||
start_data: dict[str, Any]
|
||||
def __init__(self, config_entry: ConfigEntry) -> None:
|
||||
"""Initialize options flow."""
|
||||
self.last_rendered_recommended = config_entry.options.get(
|
||||
CONF_RECOMMENDED, False
|
||||
)
|
||||
self._genai_client = config_entry.runtime_data
|
||||
|
||||
@property
|
||||
def _genai_client(self) -> genai.Client:
|
||||
"""Return the Google Generative AI client."""
|
||||
return self._get_entry().runtime_data
|
||||
|
||||
async def async_step_user(
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Add a subentry."""
|
||||
self.is_new = True
|
||||
if self._subentry_type == "ai_task":
|
||||
self.start_data = RECOMMENDED_AI_TASK_OPTIONS.copy()
|
||||
else:
|
||||
self.start_data = RECOMMENDED_CONVERSATION_OPTIONS.copy()
|
||||
return await self.async_step_set_options()
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Handle reconfiguration of a subentry."""
|
||||
self.is_new = False
|
||||
self.start_data = self._get_reconfigure_subentry().data.copy()
|
||||
return await self.async_step_set_options()
|
||||
|
||||
async def async_step_set_options(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Set conversation options."""
|
||||
options = self.start_data
|
||||
) -> ConfigFlowResult:
|
||||
"""Manage the options."""
|
||||
options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended:
|
||||
if not user_input.get(CONF_LLM_HASS_API):
|
||||
user_input.pop(CONF_LLM_HASS_API, None)
|
||||
# Don't allow to save options that enable the Google Search tool with an Assist API
|
||||
if not (
|
||||
user_input.get(CONF_LLM_HASS_API)
|
||||
and user_input.get(CONF_USE_GOOGLE_SEARCH_TOOL, False) is True
|
||||
):
|
||||
if self.is_new:
|
||||
return self.async_create_entry(
|
||||
title=user_input.pop(CONF_NAME),
|
||||
data=user_input,
|
||||
)
|
||||
|
||||
return self.async_update_and_abort(
|
||||
self._get_entry(),
|
||||
self._get_reconfigure_subentry(),
|
||||
data=user_input,
|
||||
)
|
||||
# Don't allow to save options that enable the Google Seearch tool with an Assist API
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
errors[CONF_USE_GOOGLE_SEARCH_TOOL] = "invalid_google_search_option"
|
||||
|
||||
# Re-render the options again, now with the recommended options shown/hidden
|
||||
self.last_rendered_recommended = user_input[CONF_RECOMMENDED]
|
||||
|
||||
options = user_input
|
||||
else:
|
||||
self.last_rendered_recommended = options.get(CONF_RECOMMENDED, False)
|
||||
|
||||
schema = await google_generative_ai_config_option_schema(
|
||||
self.hass, self.is_new, self._subentry_type, options, self._genai_client
|
||||
self.hass, options, self._genai_client
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="set_options", data_schema=vol.Schema(schema), errors=errors
|
||||
step_id="init", data_schema=vol.Schema(schema), errors=errors
|
||||
)
|
||||
|
||||
|
||||
async def google_generative_ai_config_option_schema(
|
||||
hass: HomeAssistant,
|
||||
is_new: bool,
|
||||
subentry_type: str,
|
||||
options: Mapping[str, Any],
|
||||
genai_client: genai.Client,
|
||||
) -> dict:
|
||||
@@ -274,57 +224,29 @@ async def google_generative_ai_config_option_schema(
|
||||
):
|
||||
suggested_llm_apis = [suggested_llm_apis]
|
||||
|
||||
if is_new:
|
||||
if CONF_NAME in options:
|
||||
default_name = options[CONF_NAME]
|
||||
elif subentry_type == "ai_task":
|
||||
default_name = DEFAULT_AI_TASK_NAME
|
||||
else:
|
||||
default_name = DEFAULT_CONVERSATION_NAME
|
||||
schema: dict[vol.Required | vol.Optional, Any] = {
|
||||
vol.Required(CONF_NAME, default=default_name): str,
|
||||
}
|
||||
else:
|
||||
schema = {}
|
||||
|
||||
if subentry_type == "conversation":
|
||||
schema.update(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_PROMPT,
|
||||
description={
|
||||
"suggested_value": options.get(
|
||||
CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT
|
||||
)
|
||||
},
|
||||
): TemplateSelector(),
|
||||
vol.Optional(
|
||||
CONF_LLM_HASS_API,
|
||||
description={"suggested_value": suggested_llm_apis},
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(options=hass_apis, multiple=True)
|
||||
),
|
||||
vol.Required(
|
||||
CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)
|
||||
): bool,
|
||||
}
|
||||
)
|
||||
else:
|
||||
# For ai_task and tts subentry types
|
||||
schema.update(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)
|
||||
): bool,
|
||||
}
|
||||
)
|
||||
schema = {
|
||||
vol.Optional(
|
||||
CONF_PROMPT,
|
||||
description={
|
||||
"suggested_value": options.get(
|
||||
CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT
|
||||
)
|
||||
},
|
||||
): TemplateSelector(),
|
||||
vol.Optional(
|
||||
CONF_LLM_HASS_API,
|
||||
description={"suggested_value": suggested_llm_apis},
|
||||
): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)),
|
||||
vol.Required(
|
||||
CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)
|
||||
): bool,
|
||||
}
|
||||
|
||||
if options.get(CONF_RECOMMENDED):
|
||||
return schema
|
||||
|
||||
api_models_pager = await genai_client.aio.models.list(config={"query_base": True})
|
||||
api_models = [api_model async for api_model in api_models_pager]
|
||||
|
||||
models = [
|
||||
SelectOptionDict(
|
||||
label=api_model.display_name,
|
||||
@@ -420,17 +342,13 @@ async def google_generative_ai_config_option_schema(
|
||||
},
|
||||
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
|
||||
): harm_block_thresholds_selector,
|
||||
}
|
||||
)
|
||||
if subentry_type == "conversation":
|
||||
schema[
|
||||
vol.Optional(
|
||||
CONF_USE_GOOGLE_SEARCH_TOOL,
|
||||
description={
|
||||
"suggested_value": options.get(CONF_USE_GOOGLE_SEARCH_TOOL),
|
||||
},
|
||||
default=RECOMMENDED_USE_GOOGLE_SEARCH_TOOL,
|
||||
)
|
||||
] = bool
|
||||
|
||||
): bool,
|
||||
}
|
||||
)
|
||||
return schema
|
||||
|
||||
@@ -2,17 +2,10 @@
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.const import CONF_LLM_HASS_API
|
||||
from homeassistant.helpers import llm
|
||||
|
||||
DOMAIN = "google_generative_ai_conversation"
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
CONF_PROMPT = "prompt"
|
||||
|
||||
DEFAULT_CONVERSATION_NAME = "Google Conversation"
|
||||
DEFAULT_AI_TASK_NAME = "Google AI Task"
|
||||
|
||||
|
||||
ATTR_MODEL = "model"
|
||||
CONF_RECOMMENDED = "recommended"
|
||||
CONF_CHAT_MODEL = "chat_model"
|
||||
@@ -36,13 +29,3 @@ RECOMMENDED_USE_GOOGLE_SEARCH_TOOL = False
|
||||
|
||||
TIMEOUT_MILLIS = 10000
|
||||
FILE_POLLING_INTERVAL_SECONDS = 0.05
|
||||
|
||||
RECOMMENDED_CONVERSATION_OPTIONS = {
|
||||
CONF_RECOMMENDED: True,
|
||||
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
|
||||
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
|
||||
}
|
||||
|
||||
RECOMMENDED_AI_TASK_OPTIONS = {
|
||||
CONF_RECOMMENDED: True,
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from typing import Literal
|
||||
|
||||
from homeassistant.components import assist_pipeline, conversation
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||
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
|
||||
@@ -22,14 +22,8 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up conversation entities."""
|
||||
for subentry in config_entry.subentries.values():
|
||||
if subentry.subentry_type != "conversation":
|
||||
continue
|
||||
|
||||
async_add_entities(
|
||||
[GoogleGenerativeAIConversationEntity(config_entry, subentry)],
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
)
|
||||
agent = GoogleGenerativeAIConversationEntity(config_entry)
|
||||
async_add_entities([agent])
|
||||
|
||||
|
||||
class GoogleGenerativeAIConversationEntity(
|
||||
@@ -41,10 +35,10 @@ class GoogleGenerativeAIConversationEntity(
|
||||
|
||||
_attr_supports_streaming = True
|
||||
|
||||
def __init__(self, entry: ConfigEntry, subentry: ConfigSubentry) -> None:
|
||||
def __init__(self, entry: ConfigEntry) -> None:
|
||||
"""Initialize the agent."""
|
||||
super().__init__(entry, subentry)
|
||||
if self.subentry.data.get(CONF_LLM_HASS_API):
|
||||
super().__init__(entry)
|
||||
if self.entry.options.get(CONF_LLM_HASS_API):
|
||||
self._attr_supported_features = (
|
||||
conversation.ConversationEntityFeature.CONTROL
|
||||
)
|
||||
@@ -76,7 +70,7 @@ class GoogleGenerativeAIConversationEntity(
|
||||
chat_log: conversation.ChatLog,
|
||||
) -> conversation.ConversationResult:
|
||||
"""Call the API."""
|
||||
options = self.subentry.data
|
||||
options = self.entry.options
|
||||
|
||||
try:
|
||||
await chat_log.async_provide_llm_data(
|
||||
|
||||
@@ -24,7 +24,7 @@ from google.genai.types import (
|
||||
from voluptuous_openapi import convert
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, llm
|
||||
from homeassistant.helpers.entity import Entity
|
||||
@@ -40,7 +40,6 @@ from .const import (
|
||||
CONF_TOP_K,
|
||||
CONF_TOP_P,
|
||||
CONF_USE_GOOGLE_SEARCH_TOOL,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
@@ -302,13 +301,14 @@ async def _transform_stream(
|
||||
class GoogleGenerativeAILLMBaseEntity(Entity):
|
||||
"""Google Generative AI base entity."""
|
||||
|
||||
def __init__(self, entry: ConfigEntry, subentry: ConfigSubentry) -> None:
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
|
||||
def __init__(self, entry: ConfigEntry) -> None:
|
||||
"""Initialize the agent."""
|
||||
self.entry = entry
|
||||
self.subentry = subentry
|
||||
self._attr_name = subentry.title or DEFAULT_CONVERSATION_NAME
|
||||
self._genai_client = entry.runtime_data
|
||||
self._attr_unique_id = subentry.subentry_id
|
||||
self._attr_unique_id = entry.entry_id
|
||||
self._attr_device_info = dr.DeviceInfo(
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
name=entry.title,
|
||||
@@ -322,7 +322,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
|
||||
chat_log: conversation.ChatLog,
|
||||
) -> None:
|
||||
"""Generate an answer for the chat log."""
|
||||
options = self.subentry.data
|
||||
options = self.entry.options
|
||||
|
||||
tools: list[Tool | Callable[..., Any]] | None = None
|
||||
if chat_log.llm_api:
|
||||
|
||||
@@ -21,71 +21,32 @@
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
}
|
||||
},
|
||||
"config_subentries": {
|
||||
"conversation": {
|
||||
"initiate_flow": {
|
||||
"user": "Add conversation agent",
|
||||
"reconfigure": "Reconfigure conversation agent"
|
||||
},
|
||||
"entry_type": "Conversation agent",
|
||||
|
||||
"step": {
|
||||
"set_options": {
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"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%]",
|
||||
"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",
|
||||
"enable_google_search_tool": "Enable Google Search tool"
|
||||
},
|
||||
"data_description": {
|
||||
"prompt": "Instruct how the LLM should respond. This can be a template.",
|
||||
"enable_google_search_tool": "Only works if there is nothing selected in the \"Control Home Assistant\" setting. See docs for a workaround using it with \"Assist\"."
|
||||
}
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"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%]",
|
||||
"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",
|
||||
"enable_google_search_tool": "Enable Google Search tool"
|
||||
},
|
||||
"data_description": {
|
||||
"prompt": "Instruct how the LLM should respond. This can be a template.",
|
||||
"enable_google_search_tool": "Only works if there is nothing selected in the \"Control Home Assistant\" setting. See docs for a workaround using it with \"Assist\"."
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"invalid_google_search_option": "Google Search can only be enabled if nothing is selected in the \"Control Home Assistant\" setting."
|
||||
}
|
||||
},
|
||||
"ai_task": {
|
||||
"initiate_flow": {
|
||||
"user": "Add AI task service",
|
||||
"reconfigure": "Reconfigure AI task service"
|
||||
},
|
||||
"entry_type": "AI task service",
|
||||
"step": {
|
||||
"set_options": {
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"recommended": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::recommended%]",
|
||||
"chat_model": "[%key:common::generic::model%]",
|
||||
"temperature": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::temperature%]",
|
||||
"top_p": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_p%]",
|
||||
"top_k": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_k%]",
|
||||
"max_tokens": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::max_tokens%]",
|
||||
"harassment_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::harassment_block_threshold%]",
|
||||
"hate_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::hate_block_threshold%]",
|
||||
"sexual_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::sexual_block_threshold%]",
|
||||
"dangerous_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::dangerous_block_threshold%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
}
|
||||
"error": {
|
||||
"invalid_google_search_option": "Google Search can only be enabled if nothing is selected in the \"Control Home Assistant\" setting."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -29,7 +29,8 @@ async def update_addon(
|
||||
client = get_supervisor_client(hass)
|
||||
|
||||
if backup:
|
||||
from .backup import backup_addon_before_update # noqa: PLC0415
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from .backup import backup_addon_before_update
|
||||
|
||||
await backup_addon_before_update(hass, addon, addon_name, installed_version)
|
||||
|
||||
@@ -49,7 +50,8 @@ async def update_core(hass: HomeAssistant, version: str | None, backup: bool) ->
|
||||
client = get_supervisor_client(hass)
|
||||
|
||||
if backup:
|
||||
from .backup import backup_core_before_update # noqa: PLC0415
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from .backup import backup_core_before_update
|
||||
|
||||
await backup_core_before_update(hass)
|
||||
|
||||
@@ -69,7 +71,8 @@ async def update_os(hass: HomeAssistant, version: str | None, backup: bool) -> N
|
||||
client = get_supervisor_client(hass)
|
||||
|
||||
if backup:
|
||||
from .backup import backup_core_before_update # noqa: PLC0415
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from .backup import backup_core_before_update
|
||||
|
||||
await backup_core_before_update(hass)
|
||||
|
||||
|
||||
@@ -100,11 +100,9 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData]
|
||||
try:
|
||||
response = await self._api.route(
|
||||
transport_mode=TransportMode(params.travel_mode),
|
||||
origin=here_routing.Place(
|
||||
float(params.origin[0]), float(params.origin[1])
|
||||
),
|
||||
origin=here_routing.Place(params.origin[0], params.origin[1]),
|
||||
destination=here_routing.Place(
|
||||
float(params.destination[0]), float(params.destination[1])
|
||||
params.destination[0], params.destination[1]
|
||||
),
|
||||
routing_mode=params.route_mode,
|
||||
arrival_time=params.arrival,
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/here_travel_time",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["here_routing", "here_transit", "homeassistant.helpers.location"],
|
||||
"requirements": ["here-routing==1.2.0", "here-transit==1.2.1"]
|
||||
"requirements": ["here-routing==1.0.1", "here-transit==1.2.1"]
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import TypedDict
|
||||
|
||||
from here_routing import RoutingMode
|
||||
|
||||
|
||||
class HERETravelTimeData(TypedDict):
|
||||
"""Routing information."""
|
||||
@@ -29,6 +27,6 @@ class HERETravelTimeAPIParams:
|
||||
destination: list[str]
|
||||
origin: list[str]
|
||||
travel_mode: str
|
||||
route_mode: RoutingMode
|
||||
route_mode: str
|
||||
arrival: datetime | None
|
||||
departure: datetime | None
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/holiday",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["holidays==0.75", "babel==2.15.0"]
|
||||
"requirements": ["holidays==0.74", "babel==2.15.0"]
|
||||
}
|
||||
|
||||
@@ -22,6 +22,6 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aiohomeconnect"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiohomeconnect==0.18.1"],
|
||||
"requirements": ["aiohomeconnect==0.18.0"],
|
||||
"zeroconf": ["_homeconnect._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -309,7 +309,8 @@ class OptionsFlowHandler(OptionsFlow, ABC):
|
||||
|
||||
def __init__(self, config_entry: ConfigEntry) -> None:
|
||||
"""Set up the options flow."""
|
||||
from homeassistant.components.zha.radio_manager import ( # noqa: PLC0415
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from homeassistant.components.zha.radio_manager import (
|
||||
ZhaMultiPANMigrationHelper,
|
||||
)
|
||||
|
||||
@@ -450,11 +451,16 @@ class OptionsFlowHandler(OptionsFlow, ABC):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Configure the Silicon Labs Multiprotocol add-on."""
|
||||
from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN # noqa: PLC0415
|
||||
from homeassistant.components.zha.radio_manager import ( # noqa: PLC0415
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN
|
||||
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from homeassistant.components.zha.radio_manager import (
|
||||
ZhaMultiPANMigrationHelper,
|
||||
)
|
||||
from homeassistant.components.zha.silabs_multiprotocol import ( # noqa: PLC0415
|
||||
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from homeassistant.components.zha.silabs_multiprotocol import (
|
||||
async_get_channel as async_get_zha_channel,
|
||||
)
|
||||
|
||||
@@ -741,8 +747,11 @@ class OptionsFlowHandler(OptionsFlow, ABC):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Perform initial backup and reconfigure ZHA."""
|
||||
from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN # noqa: PLC0415
|
||||
from homeassistant.components.zha.radio_manager import ( # noqa: PLC0415
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN
|
||||
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from homeassistant.components.zha.radio_manager import (
|
||||
ZhaMultiPANMigrationHelper,
|
||||
)
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> boo
|
||||
entry.runtime_data = homee
|
||||
entry.async_on_unload(homee.disconnect)
|
||||
|
||||
async def _connection_update_callback(connected: bool) -> None:
|
||||
def _connection_update_callback(connected: bool) -> None:
|
||||
"""Call when the device is notified of changes."""
|
||||
if connected:
|
||||
_LOGGER.warning("Reconnected to Homee at %s", entry.data[CONF_HOST])
|
||||
|
||||
@@ -28,7 +28,6 @@ class HomeeEntity(Entity):
|
||||
self._entry = entry
|
||||
node = entry.runtime_data.get_node_by_id(attribute.node_id)
|
||||
# Homee hub itself has node-id -1
|
||||
assert node is not None
|
||||
if node.id == -1:
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, entry.runtime_data.settings.uid)},
|
||||
@@ -42,8 +41,6 @@ class HomeeEntity(Entity):
|
||||
model=get_name_for_enum(NodeProfile, node.profile),
|
||||
via_device=(DOMAIN, entry.runtime_data.settings.uid),
|
||||
)
|
||||
if attribute.name:
|
||||
self._attr_name = attribute.name
|
||||
|
||||
self._host_connected = entry.runtime_data.connected
|
||||
|
||||
@@ -82,7 +79,7 @@ class HomeeEntity(Entity):
|
||||
def _on_node_updated(self, attribute: HomeeAttribute) -> None:
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
async def _on_connection_changed(self, connected: bool) -> None:
|
||||
def _on_connection_changed(self, connected: bool) -> None:
|
||||
self._host_connected = connected
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
@@ -169,6 +166,6 @@ class HomeeNodeEntity(Entity):
|
||||
def _on_node_updated(self, node: HomeeNode) -> None:
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
async def _on_connection_changed(self, connected: bool) -> None:
|
||||
def _on_connection_changed(self, connected: bool) -> None:
|
||||
self._host_connected = connected
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
@@ -58,13 +58,9 @@ class HomeeLock(HomeeEntity, LockEntity):
|
||||
AttributeChangedBy, self._attribute.changed_by
|
||||
)
|
||||
if self._attribute.changed_by == AttributeChangedBy.USER:
|
||||
user = self._entry.runtime_data.get_user_by_id(
|
||||
changed_id = self._entry.runtime_data.get_user_by_id(
|
||||
self._attribute.changed_by_id
|
||||
)
|
||||
if user is not None:
|
||||
changed_id = user.username
|
||||
else:
|
||||
changed_id = "Unknown"
|
||||
).username
|
||||
|
||||
return f"{changed_by_name}-{changed_id}"
|
||||
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["homee"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyHomee==1.2.10"]
|
||||
"requirements": ["pyHomee==1.2.8"]
|
||||
}
|
||||
|
||||
@@ -177,9 +177,9 @@
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
"upper": "[%key:component::homee::entity::event::button_state::state_attributes::event_type::state::upper%]",
|
||||
"lower": "[%key:component::homee::entity::event::button_state::state_attributes::event_type::state::lower%]",
|
||||
"released": "[%key:component::homee::entity::event::button_state::state_attributes::event_type::state::released%]"
|
||||
"upper": "[%key;component::homee::entity::event::button_state::state_attributes::event_type::state::upper%]",
|
||||
"lower": "[%key;component::homee::entity::event::button_state::state_attributes::event_type::state::lower%]",
|
||||
"released": "[%key;component::homee::entity::event::button_state::state_attributes::event_type::state::released%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,7 +189,7 @@
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
"release": "[%key:component::homee::entity::event::button_state::state_attributes::event_type::state::released%]",
|
||||
"release": "[%key;component::homee::entity::event::button_state::state_attributes::event_type::state::released%]",
|
||||
"up": "Up",
|
||||
"down": "Down",
|
||||
"stop": "Stop",
|
||||
|
||||
@@ -28,7 +28,6 @@ def get_device_class(
|
||||
) -> SwitchDeviceClass:
|
||||
"""Check device class of Switch according to node profile."""
|
||||
node = config_entry.runtime_data.get_node_by_id(attribute.node_id)
|
||||
assert node is not None
|
||||
if node.profile in [
|
||||
NodeProfile.ON_OFF_PLUG,
|
||||
NodeProfile.METERING_PLUG,
|
||||
|
||||
@@ -355,10 +355,11 @@ class HomekitControllerFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_abort(reason="ignored_model")
|
||||
|
||||
# Late imports in case BLE is not available
|
||||
from aiohomekit.controller.ble.discovery import BleDiscovery # noqa: PLC0415
|
||||
from aiohomekit.controller.ble.manufacturer_data import ( # noqa: PLC0415
|
||||
HomeKitAdvertisement,
|
||||
)
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from aiohomekit.controller.ble.discovery import BleDiscovery
|
||||
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from aiohomekit.controller.ble.manufacturer_data import HomeKitAdvertisement
|
||||
|
||||
mfr_data = discovery_info.manufacturer_data
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/homematicip_cloud",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["homematicip"],
|
||||
"requirements": ["homematicip==2.0.6"]
|
||||
"requirements": ["homematicip==2.0.5"]
|
||||
}
|
||||
|
||||
@@ -8,13 +8,7 @@ import logging
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "homewizard"
|
||||
PLATFORMS = [
|
||||
Platform.BUTTON,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
PLATFORMS = [Platform.BUTTON, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH]
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from homewizard_energy.errors import DisabledError, RequestError, UnauthorizedError
|
||||
from homewizard_energy.errors import DisabledError, RequestError
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
@@ -41,10 +41,5 @@ def homewizard_exception_handler[_HomeWizardEntityT: HomeWizardEntity, **_P](
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_disabled",
|
||||
) from ex
|
||||
except UnauthorizedError as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_unauthorized",
|
||||
) from ex
|
||||
|
||||
return handler
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
"""Support for HomeWizard select platform."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from homewizard_energy import HomeWizardEnergy
|
||||
from homewizard_energy.models import Batteries, CombinedModels as DeviceResponseEntry
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import HomeWizardConfigEntry, HWEnergyDeviceUpdateCoordinator
|
||||
from .entity import HomeWizardEntity
|
||||
from .helpers import homewizard_exception_handler
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class HomeWizardSelectEntityDescription(SelectEntityDescription):
|
||||
"""Class describing HomeWizard select entities."""
|
||||
|
||||
available_fn: Callable[[DeviceResponseEntry], bool]
|
||||
create_fn: Callable[[DeviceResponseEntry], bool]
|
||||
current_fn: Callable[[DeviceResponseEntry], str | None]
|
||||
set_fn: Callable[[HomeWizardEnergy, str], Awaitable[Any]]
|
||||
|
||||
|
||||
DESCRIPTIONS = [
|
||||
HomeWizardSelectEntityDescription(
|
||||
key="battery_group_mode",
|
||||
translation_key="battery_group_mode",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
entity_registry_enabled_default=False,
|
||||
options=[Batteries.Mode.ZERO, Batteries.Mode.STANDBY, Batteries.Mode.TO_FULL],
|
||||
available_fn=lambda x: x.batteries is not None,
|
||||
create_fn=lambda x: x.batteries is not None,
|
||||
current_fn=lambda x: x.batteries.mode if x.batteries else None,
|
||||
set_fn=lambda api, mode: api.batteries(mode=Batteries.Mode(mode)),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HomeWizardConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up HomeWizard select based on a config entry."""
|
||||
async_add_entities(
|
||||
HomeWizardSelectEntity(
|
||||
coordinator=entry.runtime_data,
|
||||
description=description,
|
||||
)
|
||||
for description in DESCRIPTIONS
|
||||
if description.create_fn(entry.runtime_data.data)
|
||||
)
|
||||
|
||||
|
||||
class HomeWizardSelectEntity(HomeWizardEntity, SelectEntity):
|
||||
"""Defines a HomeWizard select entity."""
|
||||
|
||||
entity_description: HomeWizardSelectEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HWEnergyDeviceUpdateCoordinator,
|
||||
description: HomeWizardSelectEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the switch."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}"
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return the selected entity option to represent the entity state."""
|
||||
return self.entity_description.current_fn(self.coordinator.data)
|
||||
|
||||
@homewizard_exception_handler
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Change the selected option."""
|
||||
await self.entity_description.set_fn(self.coordinator.api, option)
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -152,27 +152,14 @@
|
||||
"cloud_connection": {
|
||||
"name": "Cloud connection"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"battery_group_mode": {
|
||||
"name": "Battery group mode",
|
||||
"state": {
|
||||
"zero": "Zero mode",
|
||||
"to_full": "Manual charge mode",
|
||||
"standby": "Standby"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"api_disabled": {
|
||||
"message": "The local API is disabled."
|
||||
},
|
||||
"api_unauthorized": {
|
||||
"message": "The local API is unauthorized. Restore API access by following the instructions in the repair issue."
|
||||
},
|
||||
"communication_error": {
|
||||
"message": "An error occurred while communicating with your HomeWizard Energy device"
|
||||
"message": "An error occurred while communicating with HomeWizard device"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
|
||||
@@ -278,7 +278,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
ssl_certificate is not None
|
||||
and (hass.config.external_url or hass.config.internal_url) is None
|
||||
):
|
||||
from homeassistant.components.cloud import ( # noqa: PLC0415
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from homeassistant.components.cloud import (
|
||||
CloudNotAvailable,
|
||||
async_remote_ui_url,
|
||||
)
|
||||
@@ -510,14 +511,12 @@ class HomeAssistantHTTP:
|
||||
) -> None:
|
||||
"""Register a folder or file to serve as a static path."""
|
||||
frame.report_usage(
|
||||
"calls hass.http.register_static_path which "
|
||||
"does blocking I/O in the event loop, instead "
|
||||
"calls hass.http.register_static_path which is deprecated because "
|
||||
"it does blocking I/O in the event loop, instead "
|
||||
"call `await hass.http.async_register_static_paths("
|
||||
f'[StaticPathConfig("{url_path}", "{path}", {cache_headers})])`',
|
||||
exclude_integrations={"http"},
|
||||
core_behavior=frame.ReportBehavior.ERROR,
|
||||
core_integration_behavior=frame.ReportBehavior.ERROR,
|
||||
custom_integration_behavior=frame.ReportBehavior.ERROR,
|
||||
core_behavior=frame.ReportBehavior.LOG,
|
||||
breaks_in_ha_version="2025.7",
|
||||
)
|
||||
configs = [StaticPathConfig(url_path, path, cache_headers)]
|
||||
|
||||
@@ -136,7 +136,8 @@ async def process_wrong_login(request: Request) -> None:
|
||||
_LOGGER.warning(log_msg)
|
||||
|
||||
# Circular import with websocket_api
|
||||
from homeassistant.components import persistent_notification # noqa: PLC0415
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from homeassistant.components import persistent_notification
|
||||
|
||||
persistent_notification.async_create(
|
||||
hass, notification_msg, "Login attempt failed", NOTIFICATION_ID_LOGIN
|
||||
|
||||
@@ -444,9 +444,8 @@ class TimerManager:
|
||||
timer.finish()
|
||||
|
||||
if timer.conversation_command:
|
||||
from homeassistant.components.conversation import ( # noqa: PLC0415
|
||||
async_converse,
|
||||
)
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from homeassistant.components.conversation import async_converse
|
||||
|
||||
self.hass.async_create_background_task(
|
||||
async_converse(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""The JuiceNet integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
import aiohttp
|
||||
@@ -13,9 +14,9 @@ from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR
|
||||
from .coordinator import JuiceNetCoordinator
|
||||
from .device import JuiceNetApi
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -73,7 +74,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
return False
|
||||
_LOGGER.debug("%d JuiceNet device(s) found", len(juicenet.devices))
|
||||
|
||||
coordinator = JuiceNetCoordinator(hass, entry, juicenet)
|
||||
async def async_update_data():
|
||||
"""Update all device states from the JuiceNet API."""
|
||||
for device in juicenet.devices:
|
||||
await device.update_state(True)
|
||||
return True
|
||||
|
||||
coordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name="JuiceNet",
|
||||
update_method=async_update_data,
|
||||
update_interval=timedelta(seconds=30),
|
||||
)
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"""The JuiceNet integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .device import JuiceNetApi
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JuiceNetCoordinator(DataUpdateCoordinator[None]):
|
||||
"""Coordinator for JuiceNet."""
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, entry: ConfigEntry, juicenet_api: JuiceNetApi
|
||||
) -> None:
|
||||
"""Initialize the JuiceNet coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name="JuiceNet",
|
||||
update_interval=timedelta(seconds=30),
|
||||
)
|
||||
self.juicenet_api = juicenet_api
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
for device in self.juicenet_api.devices:
|
||||
await device.update_state(True)
|
||||
@@ -1,21 +1,19 @@
|
||||
"""Adapter to wrap the pyjuicenet api for home assistant."""
|
||||
|
||||
from pyjuicenet import Api, Charger
|
||||
|
||||
|
||||
class JuiceNetApi:
|
||||
"""Represent a connection to JuiceNet."""
|
||||
|
||||
def __init__(self, api: Api) -> None:
|
||||
def __init__(self, api):
|
||||
"""Create an object from the provided API instance."""
|
||||
self.api = api
|
||||
self._devices: list[Charger] = []
|
||||
self._devices = []
|
||||
|
||||
async def setup(self) -> None:
|
||||
async def setup(self):
|
||||
"""JuiceNet device setup."""
|
||||
self._devices = await self.api.get_devices()
|
||||
|
||||
@property
|
||||
def devices(self) -> list[Charger]:
|
||||
def devices(self) -> list:
|
||||
"""Get a list of devices managed by this account."""
|
||||
return self._devices
|
||||
|
||||
@@ -3,19 +3,21 @@
|
||||
from pyjuicenet import Charger
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import JuiceNetCoordinator
|
||||
|
||||
|
||||
class JuiceNetEntity(CoordinatorEntity[JuiceNetCoordinator]):
|
||||
class JuiceNetDevice(CoordinatorEntity):
|
||||
"""Represent a base JuiceNet device."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self, device: Charger, key: str, coordinator: JuiceNetCoordinator
|
||||
self, device: Charger, key: str, coordinator: DataUpdateCoordinator
|
||||
) -> None:
|
||||
"""Initialise the sensor."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pyjuicenet import Charger
|
||||
from pyjuicenet import Api, Charger
|
||||
|
||||
from homeassistant.components.number import (
|
||||
DEFAULT_MAX_VALUE,
|
||||
@@ -14,11 +14,10 @@ from homeassistant.components.number import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR
|
||||
from .coordinator import JuiceNetCoordinator
|
||||
from .device import JuiceNetApi
|
||||
from .entity import JuiceNetEntity
|
||||
from .entity import JuiceNetDevice
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
@@ -48,8 +47,8 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the JuiceNet Numbers."""
|
||||
juicenet_data = hass.data[DOMAIN][config_entry.entry_id]
|
||||
api: JuiceNetApi = juicenet_data[JUICENET_API]
|
||||
coordinator: JuiceNetCoordinator = juicenet_data[JUICENET_COORDINATOR]
|
||||
api: Api = juicenet_data[JUICENET_API]
|
||||
coordinator = juicenet_data[JUICENET_COORDINATOR]
|
||||
|
||||
entities = [
|
||||
JuiceNetNumber(device, description, coordinator)
|
||||
@@ -59,7 +58,7 @@ async def async_setup_entry(
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class JuiceNetNumber(JuiceNetEntity, NumberEntity):
|
||||
class JuiceNetNumber(JuiceNetDevice, NumberEntity):
|
||||
"""Implementation of a JuiceNet number."""
|
||||
|
||||
entity_description: JuiceNetNumberEntityDescription
|
||||
@@ -68,7 +67,7 @@ class JuiceNetNumber(JuiceNetEntity, NumberEntity):
|
||||
self,
|
||||
device: Charger,
|
||||
description: JuiceNetNumberEntityDescription,
|
||||
coordinator: JuiceNetCoordinator,
|
||||
coordinator: DataUpdateCoordinator,
|
||||
) -> None:
|
||||
"""Initialise the number."""
|
||||
super().__init__(device, description.key, coordinator)
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pyjuicenet import Charger
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
@@ -23,9 +21,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR
|
||||
from .coordinator import JuiceNetCoordinator
|
||||
from .device import JuiceNetApi
|
||||
from .entity import JuiceNetEntity
|
||||
from .entity import JuiceNetDevice
|
||||
|
||||
SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
@@ -78,8 +74,8 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the JuiceNet Sensors."""
|
||||
juicenet_data = hass.data[DOMAIN][config_entry.entry_id]
|
||||
api: JuiceNetApi = juicenet_data[JUICENET_API]
|
||||
coordinator: JuiceNetCoordinator = juicenet_data[JUICENET_COORDINATOR]
|
||||
api = juicenet_data[JUICENET_API]
|
||||
coordinator = juicenet_data[JUICENET_COORDINATOR]
|
||||
|
||||
entities = [
|
||||
JuiceNetSensorDevice(device, coordinator, description)
|
||||
@@ -89,14 +85,11 @@ async def async_setup_entry(
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class JuiceNetSensorDevice(JuiceNetEntity, SensorEntity):
|
||||
class JuiceNetSensorDevice(JuiceNetDevice, SensorEntity):
|
||||
"""Implementation of a JuiceNet sensor."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: Charger,
|
||||
coordinator: JuiceNetCoordinator,
|
||||
description: SensorEntityDescription,
|
||||
self, device, coordinator, description: SensorEntityDescription
|
||||
) -> None:
|
||||
"""Initialise the sensor."""
|
||||
super().__init__(device, description.key, coordinator)
|
||||
|
||||
@@ -2,17 +2,13 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pyjuicenet import Charger
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR
|
||||
from .coordinator import JuiceNetCoordinator
|
||||
from .device import JuiceNetApi
|
||||
from .entity import JuiceNetEntity
|
||||
from .entity import JuiceNetDevice
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -22,20 +18,20 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the JuiceNet switches."""
|
||||
juicenet_data = hass.data[DOMAIN][config_entry.entry_id]
|
||||
api: JuiceNetApi = juicenet_data[JUICENET_API]
|
||||
coordinator: JuiceNetCoordinator = juicenet_data[JUICENET_COORDINATOR]
|
||||
api = juicenet_data[JUICENET_API]
|
||||
coordinator = juicenet_data[JUICENET_COORDINATOR]
|
||||
|
||||
async_add_entities(
|
||||
JuiceNetChargeNowSwitch(device, coordinator) for device in api.devices
|
||||
)
|
||||
|
||||
|
||||
class JuiceNetChargeNowSwitch(JuiceNetEntity, SwitchEntity):
|
||||
class JuiceNetChargeNowSwitch(JuiceNetDevice, SwitchEntity):
|
||||
"""Implementation of a JuiceNet switch."""
|
||||
|
||||
_attr_translation_key = "charge_now"
|
||||
|
||||
def __init__(self, device: Charger, coordinator: JuiceNetCoordinator) -> None:
|
||||
def __init__(self, device, coordinator):
|
||||
"""Initialise the switch."""
|
||||
super().__init__(device, "charge_now", coordinator)
|
||||
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
|
||||
from .const import PLATFORMS
|
||||
from .coordinator import JustNimbusConfigEntry, JustNimbusCoordinator
|
||||
from .const import DOMAIN, PLATFORMS
|
||||
from .coordinator import JustNimbusCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: JustNimbusConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up JustNimbus from a config entry."""
|
||||
if "zip_code" in entry.data:
|
||||
coordinator = JustNimbusCoordinator(hass, entry)
|
||||
@@ -17,11 +18,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: JustNimbusConfigEntry) -
|
||||
raise ConfigEntryAuthFailed
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: JustNimbusConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
return unload_ok
|
||||
|
||||
@@ -16,17 +16,13 @@ from .const import CONF_ZIP_CODE, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type JustNimbusConfigEntry = ConfigEntry[JustNimbusCoordinator]
|
||||
|
||||
|
||||
class JustNimbusCoordinator(DataUpdateCoordinator[justnimbus.JustNimbusModel]):
|
||||
"""Data update coordinator."""
|
||||
|
||||
config_entry: JustNimbusConfigEntry
|
||||
config_entry: ConfigEntry
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, config_entry: JustNimbusConfigEntry
|
||||
) -> None:
|
||||
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
|
||||
@@ -12,6 +12,7 @@ from homeassistant.components.sensor import (
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_CLIENT_ID,
|
||||
EntityCategory,
|
||||
@@ -23,7 +24,8 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from .coordinator import JustNimbusConfigEntry, JustNimbusCoordinator
|
||||
from . import JustNimbusCoordinator
|
||||
from .const import DOMAIN
|
||||
from .entity import JustNimbusEntity
|
||||
|
||||
|
||||
@@ -100,15 +102,16 @@ SENSOR_TYPES = (
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: JustNimbusConfigEntry,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the JustNimbus sensor."""
|
||||
coordinator: JustNimbusCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_entities(
|
||||
JustNimbusSensor(
|
||||
device_id=entry.data[CONF_CLIENT_ID],
|
||||
description=description,
|
||||
coordinator=entry.runtime_data,
|
||||
coordinator=coordinator,
|
||||
)
|
||||
for description in SENSOR_TYPES
|
||||
)
|
||||
|
||||
@@ -3,22 +3,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from kaleidescape import Device as KaleidescapeDevice, KaleidescapeError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE, Platform.SENSOR]
|
||||
|
||||
type KaleidescapeConfigEntry = ConfigEntry[KaleidescapeDevice]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: KaleidescapeConfigEntry
|
||||
) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Kaleidescape from a config entry."""
|
||||
device = KaleidescapeDevice(
|
||||
entry.data[CONF_HOST], timeout=5, reconnect=True, reconnect_delay=5
|
||||
@@ -32,7 +36,7 @@ async def async_setup_entry(
|
||||
f"Unable to connect to {entry.data[CONF_HOST]}: {err}"
|
||||
) from err
|
||||
|
||||
entry.runtime_data = device
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = device
|
||||
|
||||
async def disconnect(event: Event) -> None:
|
||||
await device.disconnect()
|
||||
@@ -40,18 +44,18 @@ async def async_setup_entry(
|
||||
entry.async_on_unload(
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, disconnect)
|
||||
)
|
||||
entry.async_on_unload(device.disconnect)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: KaleidescapeConfigEntry
|
||||
) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
await hass.data[DOMAIN][entry.entry_id].disconnect()
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
return unload_ok
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from kaleidescape import const as kaleidescape_const
|
||||
|
||||
@@ -12,13 +12,19 @@ from homeassistant.components.media_player import (
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from . import KaleidescapeConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .entity import KaleidescapeEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
|
||||
KALEIDESCAPE_PLAYING_STATES = [
|
||||
kaleidescape_const.PLAY_STATUS_PLAYING,
|
||||
kaleidescape_const.PLAY_STATUS_FORWARD,
|
||||
@@ -33,11 +39,11 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: KaleidescapeConfigEntry,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the platform from a config entry."""
|
||||
entities = [KaleidescapeMediaPlayer(entry.runtime_data)]
|
||||
entities = [KaleidescapeMediaPlayer(hass.data[DOMAIN][entry.entry_id])]
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
|
||||
@@ -2,27 +2,32 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from kaleidescape import const as kaleidescape_const
|
||||
|
||||
from homeassistant.components.remote import RemoteEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import KaleidescapeConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .entity import KaleidescapeEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: KaleidescapeConfigEntry,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the platform from a config entry."""
|
||||
entities = [KaleidescapeRemote(entry.runtime_data)]
|
||||
entities = [KaleidescapeRemote(hass.data[DOMAIN][entry.entry_id])]
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
|
||||
@@ -2,20 +2,25 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from kaleidescape import Device as KaleidescapeDevice
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from . import KaleidescapeConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .entity import KaleidescapeEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from kaleidescape import Device as KaleidescapeDevice
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class KaleidescapeSensorEntityDescription(SensorEntityDescription):
|
||||
@@ -127,11 +132,11 @@ SENSOR_TYPES: tuple[KaleidescapeSensorEntityDescription, ...] = (
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: KaleidescapeConfigEntry,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the platform from a config entry."""
|
||||
device = entry.runtime_data
|
||||
device: KaleidescapeDevice = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_entities(
|
||||
KaleidescapeSensor(device, description) for description in SENSOR_TYPES
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
@@ -18,14 +19,16 @@ from .const import (
|
||||
DEFAULT_INTERFACE,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DOMAIN,
|
||||
ROUTER,
|
||||
UNDO_UPDATE_LISTENER,
|
||||
)
|
||||
from .router import KeeneticConfigEntry, KeeneticRouter
|
||||
from .router import KeeneticRouter
|
||||
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER]
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: KeeneticConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up the component."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
async_add_defaults(hass, entry)
|
||||
@@ -33,26 +36,32 @@ async def async_setup_entry(hass: HomeAssistant, entry: KeeneticConfigEntry) ->
|
||||
router = KeeneticRouter(hass, entry)
|
||||
await router.async_setup()
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(update_listener))
|
||||
undo_listener = entry.add_update_listener(update_listener)
|
||||
|
||||
entry.runtime_data = router
|
||||
hass.data[DOMAIN][entry.entry_id] = {
|
||||
ROUTER: router,
|
||||
UNDO_UPDATE_LISTENER: undo_listener,
|
||||
}
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, config_entry: KeeneticConfigEntry
|
||||
) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
hass.data[DOMAIN][config_entry.entry_id][UNDO_UPDATE_LISTENER]()
|
||||
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(
|
||||
config_entry, PLATFORMS
|
||||
)
|
||||
|
||||
router = config_entry.runtime_data
|
||||
router: KeeneticRouter = hass.data[DOMAIN][config_entry.entry_id][ROUTER]
|
||||
|
||||
await router.async_teardown()
|
||||
|
||||
hass.data[DOMAIN].pop(config_entry.entry_id)
|
||||
|
||||
new_tracked_interfaces: set[str] = set(config_entry.options[CONF_INTERFACES])
|
||||
|
||||
if router.tracked_interfaces - new_tracked_interfaces:
|
||||
@@ -87,12 +96,12 @@ async def async_unload_entry(
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def update_listener(hass: HomeAssistant, entry: KeeneticConfigEntry) -> None:
|
||||
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Handle options update."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
def async_add_defaults(hass: HomeAssistant, entry: KeeneticConfigEntry):
|
||||
def async_add_defaults(hass: HomeAssistant, entry: ConfigEntry):
|
||||
"""Populate default options."""
|
||||
host: str = entry.data[CONF_HOST]
|
||||
imported_options: dict = hass.data[DOMAIN].get(f"imported_options_{host}", {})
|
||||
|
||||
@@ -4,20 +4,24 @@ from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .router import KeeneticConfigEntry, KeeneticRouter
|
||||
from . import KeeneticRouter
|
||||
from .const import DOMAIN, ROUTER
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: KeeneticConfigEntry,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up device tracker for Keenetic NDMS2 component."""
|
||||
async_add_entities([RouterOnlineBinarySensor(config_entry.runtime_data)])
|
||||
router: KeeneticRouter = hass.data[DOMAIN][config_entry.entry_id][ROUTER]
|
||||
|
||||
async_add_entities([RouterOnlineBinarySensor(router)])
|
||||
|
||||
|
||||
class RouterOnlineBinarySensor(BinarySensorEntity):
|
||||
|
||||
@@ -8,7 +8,12 @@ from urllib.parse import urlparse
|
||||
from ndms2_client import Client, ConnectionException, InterfaceInfo, TelnetConnection
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
@@ -36,8 +41,9 @@ from .const import (
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DEFAULT_TELNET_PORT,
|
||||
DOMAIN,
|
||||
ROUTER,
|
||||
)
|
||||
from .router import KeeneticConfigEntry
|
||||
from .router import KeeneticRouter
|
||||
|
||||
|
||||
class KeeneticFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
@@ -50,7 +56,7 @@ class KeeneticFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
config_entry: KeeneticConfigEntry,
|
||||
config_entry: ConfigEntry,
|
||||
) -> KeeneticOptionsFlowHandler:
|
||||
"""Get the options flow for this handler."""
|
||||
return KeeneticOptionsFlowHandler()
|
||||
@@ -136,8 +142,6 @@ class KeeneticFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
class KeeneticOptionsFlowHandler(OptionsFlow):
|
||||
"""Handle options."""
|
||||
|
||||
config_entry: KeeneticConfigEntry
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize options flow."""
|
||||
self._interface_options: dict[str, str] = {}
|
||||
@@ -146,7 +150,9 @@ class KeeneticOptionsFlowHandler(OptionsFlow):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Manage the options."""
|
||||
router = self.config_entry.runtime_data
|
||||
router: KeeneticRouter = self.hass.data[DOMAIN][self.config_entry.entry_id][
|
||||
ROUTER
|
||||
]
|
||||
|
||||
interfaces: list[InterfaceInfo] = await self.hass.async_add_executor_job(
|
||||
router.client.get_interfaces
|
||||
|
||||
@@ -5,6 +5,8 @@ from homeassistant.components.device_tracker import (
|
||||
)
|
||||
|
||||
DOMAIN = "keenetic_ndms2"
|
||||
ROUTER = "router"
|
||||
UNDO_UPDATE_LISTENER = "undo_update_listener"
|
||||
DEFAULT_TELNET_PORT = 23
|
||||
DEFAULT_SCAN_INTERVAL = 120
|
||||
DEFAULT_CONSIDER_HOME = _DEFAULT_CONSIDER_HOME.total_seconds()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user