forked from home-assistant/core
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1929a0d6dd | |||
| 1adab9a982 |
Generated
-4
@@ -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
|
||||
@@ -786,8 +784,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/jellyfin/ @RunC0deRun @ctalkington
|
||||
/homeassistant/components/jewish_calendar/ @tsvi
|
||||
/tests/components/jewish_calendar/ @tsvi
|
||||
/homeassistant/components/juicenet/ @jesserockz
|
||||
/tests/components/juicenet/ @jesserockz
|
||||
/homeassistant/components/justnimbus/ @kvanzuijlen
|
||||
/tests/components/justnimbus/ @kvanzuijlen
|
||||
/homeassistant/components/jvc_projector/ @SteveEasley @msavazzi
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["aioamazondevices==3.1.14"]
|
||||
"requirements": ["aioamazondevices==3.1.12"]
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
service_func=handle_ask_question,
|
||||
schema=vol.All(
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): cv.entity_domain(DOMAIN),
|
||||
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
|
||||
vol.Optional("question"): str,
|
||||
vol.Optional("question_media_id"): str,
|
||||
vol.Optional("preannounce"): bool,
|
||||
|
||||
@@ -138,6 +138,7 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
_is_announcing = False
|
||||
_extra_system_prompt: str | None = None
|
||||
_wake_word_intercept_future: asyncio.Future[str | None] | None = None
|
||||
_stt_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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -285,9 +285,9 @@ class EsphomeAssistSatellite(
|
||||
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",
|
||||
"tts_start_streaming": bool(
|
||||
event.data and event.data.get("tts_start_streaming")
|
||||
),
|
||||
}
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END:
|
||||
assert event.data is not None
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."]
|
||||
}
|
||||
|
||||
@@ -42,8 +42,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
|
||||
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["homee"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyHomee==1.2.10"]
|
||||
"requirements": ["pyHomee==1.2.9"]
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,95 +1,36 @@
|
||||
"""The JuiceNet integration."""
|
||||
|
||||
import logging
|
||||
|
||||
import aiohttp
|
||||
from pyjuicenet import Api, TokenError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
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 import issue_registry as ir
|
||||
|
||||
from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR
|
||||
from .coordinator import JuiceNetCoordinator
|
||||
from .device import JuiceNetApi
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH]
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
vol.All(
|
||||
cv.deprecated(DOMAIN),
|
||||
{DOMAIN: vol.Schema({vol.Required(CONF_ACCESS_TOKEN): cv.string})},
|
||||
),
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the JuiceNet component."""
|
||||
conf = config.get(DOMAIN)
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
if not conf:
|
||||
return True
|
||||
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_IMPORT}, data=conf
|
||||
)
|
||||
)
|
||||
return True
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up JuiceNet from a config entry."""
|
||||
|
||||
config = entry.data
|
||||
|
||||
session = async_get_clientsession(hass)
|
||||
|
||||
access_token = config[CONF_ACCESS_TOKEN]
|
||||
api = Api(access_token, session)
|
||||
|
||||
juicenet = JuiceNetApi(api)
|
||||
|
||||
try:
|
||||
await juicenet.setup()
|
||||
except TokenError as error:
|
||||
_LOGGER.error("JuiceNet Error %s", error)
|
||||
return False
|
||||
except aiohttp.ClientError as error:
|
||||
_LOGGER.error("Could not reach the JuiceNet API %s", error)
|
||||
raise ConfigEntryNotReady from error
|
||||
|
||||
if not juicenet.devices:
|
||||
_LOGGER.error("No JuiceNet devices found for this account")
|
||||
return False
|
||||
_LOGGER.debug("%d JuiceNet device(s) found", len(juicenet.devices))
|
||||
|
||||
coordinator = JuiceNetCoordinator(hass, entry, juicenet)
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
hass.data[DOMAIN][entry.entry_id] = {
|
||||
JUICENET_API: juicenet,
|
||||
JUICENET_COORDINATOR: coordinator,
|
||||
}
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
DOMAIN,
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
translation_key="integration_removed",
|
||||
translation_placeholders={
|
||||
"entries": "/config/integrations/integration/juicenet",
|
||||
},
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
return unload_ok
|
||||
if all(
|
||||
config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
for config_entry in hass.config_entries.async_entries(DOMAIN)
|
||||
if config_entry.entry_id != entry.entry_id
|
||||
):
|
||||
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
|
||||
|
||||
return True
|
||||
|
||||
@@ -1,82 +1,11 @@
|
||||
"""Config flow for JuiceNet integration."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from pyjuicenet import Api, TokenError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import core, exceptions
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.config_entries import ConfigFlow
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DATA_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str})
|
||||
|
||||
|
||||
async def validate_input(hass: core.HomeAssistant, data):
|
||||
"""Validate the user input allows us to connect.
|
||||
|
||||
Data has the keys from DATA_SCHEMA with values provided by the user.
|
||||
"""
|
||||
session = async_get_clientsession(hass)
|
||||
juicenet = Api(data[CONF_ACCESS_TOKEN], session)
|
||||
|
||||
try:
|
||||
await juicenet.get_devices()
|
||||
except TokenError as error:
|
||||
_LOGGER.error("Token Error %s", error)
|
||||
raise InvalidAuth from error
|
||||
except aiohttp.ClientError as error:
|
||||
_LOGGER.error("Error connecting %s", error)
|
||||
raise CannotConnect from error
|
||||
|
||||
# Return info that you want to store in the config entry.
|
||||
return {"title": "JuiceNet"}
|
||||
|
||||
|
||||
class JuiceNetConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for JuiceNet."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
await self.async_set_unique_id(user_input[CONF_ACCESS_TOKEN])
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
try:
|
||||
info = await validate_input(self.hass, user_input)
|
||||
return self.async_create_entry(title=info["title"], data=user_input)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Handle import."""
|
||||
return await self.async_step_user(import_data)
|
||||
|
||||
|
||||
class CannotConnect(exceptions.HomeAssistantError):
|
||||
"""Error to indicate we cannot connect."""
|
||||
|
||||
|
||||
class InvalidAuth(exceptions.HomeAssistantError):
|
||||
"""Error to indicate there is invalid auth."""
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
"""Constants used by the JuiceNet component."""
|
||||
|
||||
DOMAIN = "juicenet"
|
||||
|
||||
JUICENET_API = "juicenet_api"
|
||||
JUICENET_COORDINATOR = "juicenet_coordinator"
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
"""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:
|
||||
"""Create an object from the provided API instance."""
|
||||
self.api = api
|
||||
self._devices: list[Charger] = []
|
||||
|
||||
async def setup(self) -> None:
|
||||
"""JuiceNet device setup."""
|
||||
self._devices = await self.api.get_devices()
|
||||
|
||||
@property
|
||||
def devices(self) -> list[Charger]:
|
||||
"""Get a list of devices managed by this account."""
|
||||
return self._devices
|
||||
@@ -1,32 +0,0 @@
|
||||
"""Adapter to wrap the pyjuicenet api for home assistant."""
|
||||
|
||||
from pyjuicenet import Charger
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import JuiceNetCoordinator
|
||||
|
||||
|
||||
class JuiceNetEntity(CoordinatorEntity[JuiceNetCoordinator]):
|
||||
"""Represent a base JuiceNet device."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self, device: Charger, key: str, coordinator: JuiceNetCoordinator
|
||||
) -> None:
|
||||
"""Initialise the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.device = device
|
||||
self.key = key
|
||||
self._attr_unique_id = f"{device.id}-{key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
configuration_url=(
|
||||
f"https://home.juice.net/Portal/Details?unitID={device.id}"
|
||||
),
|
||||
identifiers={(DOMAIN, device.id)},
|
||||
manufacturer="JuiceNet",
|
||||
name=device.name,
|
||||
)
|
||||
@@ -1,10 +1,9 @@
|
||||
{
|
||||
"domain": "juicenet",
|
||||
"name": "JuiceNet",
|
||||
"codeowners": ["@jesserockz"],
|
||||
"config_flow": true,
|
||||
"codeowners": [],
|
||||
"documentation": "https://www.home-assistant.io/integrations/juicenet",
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyjuicenet"],
|
||||
"requirements": ["python-juicenet==1.1.0"]
|
||||
"requirements": []
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
"""Support for controlling juicenet/juicepoint/juicebox based EVSE numbers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pyjuicenet import Charger
|
||||
|
||||
from homeassistant.components.number import (
|
||||
DEFAULT_MAX_VALUE,
|
||||
NumberEntity,
|
||||
NumberEntityDescription,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class JuiceNetNumberEntityDescription(NumberEntityDescription):
|
||||
"""An entity description for a JuiceNetNumber."""
|
||||
|
||||
setter_key: str
|
||||
native_max_value_key: str | None = None
|
||||
|
||||
|
||||
NUMBER_TYPES: tuple[JuiceNetNumberEntityDescription, ...] = (
|
||||
JuiceNetNumberEntityDescription(
|
||||
translation_key="amperage_limit",
|
||||
key="current_charging_amperage_limit",
|
||||
native_min_value=6,
|
||||
native_max_value_key="max_charging_amperage",
|
||||
native_step=1,
|
||||
setter_key="set_charging_amperage_limit",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> 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]
|
||||
|
||||
entities = [
|
||||
JuiceNetNumber(device, description, coordinator)
|
||||
for device in api.devices
|
||||
for description in NUMBER_TYPES
|
||||
]
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class JuiceNetNumber(JuiceNetEntity, NumberEntity):
|
||||
"""Implementation of a JuiceNet number."""
|
||||
|
||||
entity_description: JuiceNetNumberEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: Charger,
|
||||
description: JuiceNetNumberEntityDescription,
|
||||
coordinator: JuiceNetCoordinator,
|
||||
) -> None:
|
||||
"""Initialise the number."""
|
||||
super().__init__(device, description.key, coordinator)
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the value of the entity."""
|
||||
return getattr(self.device, self.entity_description.key, None)
|
||||
|
||||
@property
|
||||
def native_max_value(self) -> float:
|
||||
"""Return the maximum value."""
|
||||
if self.entity_description.native_max_value_key is not None:
|
||||
return getattr(self.device, self.entity_description.native_max_value_key)
|
||||
if self.entity_description.native_max_value is not None:
|
||||
return self.entity_description.native_max_value
|
||||
return DEFAULT_MAX_VALUE
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Update the current value."""
|
||||
await getattr(self.device, self.entity_description.setter_key)(value)
|
||||
@@ -1,124 +0,0 @@
|
||||
"""Support for monitoring juicenet/juicepoint/juicebox based EVSE sensors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pyjuicenet import Charger
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfEnergy,
|
||||
UnitOfPower,
|
||||
UnitOfTemperature,
|
||||
UnitOfTime,
|
||||
)
|
||||
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
|
||||
|
||||
SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
key="status",
|
||||
name="Charging Status",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="temperature",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="voltage",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="amps",
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="watts",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="charge_time",
|
||||
translation_key="charge_time",
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
icon="mdi:timer-outline",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="energy_added",
|
||||
translation_key="energy_added",
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> 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]
|
||||
|
||||
entities = [
|
||||
JuiceNetSensorDevice(device, coordinator, description)
|
||||
for device in api.devices
|
||||
for description in SENSOR_TYPES
|
||||
]
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class JuiceNetSensorDevice(JuiceNetEntity, SensorEntity):
|
||||
"""Implementation of a JuiceNet sensor."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: Charger,
|
||||
coordinator: JuiceNetCoordinator,
|
||||
description: SensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialise the sensor."""
|
||||
super().__init__(device, description.key, coordinator)
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
"""Return the icon of the sensor."""
|
||||
icon = None
|
||||
if self.entity_description.key == "status":
|
||||
status = self.device.status
|
||||
if status == "standby":
|
||||
icon = "mdi:power-plug-off"
|
||||
elif status == "plugged":
|
||||
icon = "mdi:power-plug"
|
||||
elif status == "charging":
|
||||
icon = "mdi:battery-positive"
|
||||
else:
|
||||
icon = self.entity_description.icon
|
||||
return icon
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
"""Return the state."""
|
||||
return getattr(self.device, self.entity_description.key, None)
|
||||
@@ -1,41 +1,8 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"api_token": "[%key:common::config_flow::data::api_token%]"
|
||||
},
|
||||
"description": "You will need the API Token from https://home.juice.net/Manage.",
|
||||
"title": "Connect to JuiceNet"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"number": {
|
||||
"amperage_limit": {
|
||||
"name": "Amperage limit"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"charge_time": {
|
||||
"name": "Charge time"
|
||||
},
|
||||
"energy_added": {
|
||||
"name": "Energy added"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"charge_now": {
|
||||
"name": "Charge now"
|
||||
}
|
||||
"issues": {
|
||||
"integration_removed": {
|
||||
"title": "The JuiceNet integration has been removed",
|
||||
"description": "Enel X has dropped support for JuiceNet in favor of JuicePass, and the JuiceNet integration has been removed from Home Assistant as it was no longer working.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing JuiceNet integration entries]({entries})."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
"""Support for monitoring juicenet/juicepoint/juicebox based EVSE switches."""
|
||||
|
||||
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
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> 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]
|
||||
|
||||
async_add_entities(
|
||||
JuiceNetChargeNowSwitch(device, coordinator) for device in api.devices
|
||||
)
|
||||
|
||||
|
||||
class JuiceNetChargeNowSwitch(JuiceNetEntity, SwitchEntity):
|
||||
"""Implementation of a JuiceNet switch."""
|
||||
|
||||
_attr_translation_key = "charge_now"
|
||||
|
||||
def __init__(self, device: Charger, coordinator: JuiceNetCoordinator) -> None:
|
||||
"""Initialise the switch."""
|
||||
super().__init__(device, "charge_now", coordinator)
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if switch is on."""
|
||||
return self.device.override_time != 0
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Charge now."""
|
||||
await self.device.set_override(True)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Don't charge now."""
|
||||
await self.device.set_override(False)
|
||||
@@ -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,15 @@ from .const import (
|
||||
DEFAULT_INTERFACE,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DOMAIN,
|
||||
ROUTER,
|
||||
)
|
||||
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)
|
||||
@@ -35,24 +37,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: KeeneticConfigEntry) ->
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(update_listener))
|
||||
|
||||
entry.runtime_data = router
|
||||
hass.data[DOMAIN][entry.entry_id] = {
|
||||
ROUTER: router,
|
||||
}
|
||||
|
||||
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."""
|
||||
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 +92,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,7 @@ from homeassistant.components.device_tracker import (
|
||||
)
|
||||
|
||||
DOMAIN = "keenetic_ndms2"
|
||||
ROUTER = "router"
|
||||
DEFAULT_TELNET_PORT = 23
|
||||
DEFAULT_SCAN_INTERVAL = 120
|
||||
DEFAULT_CONSIDER_HOME = _DEFAULT_CONSIDER_HOME.total_seconds()
|
||||
|
||||
@@ -10,24 +10,26 @@ from homeassistant.components.device_tracker import (
|
||||
DOMAIN as DEVICE_TRACKER_DOMAIN,
|
||||
ScannerEntity,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .router import KeeneticConfigEntry, KeeneticRouter
|
||||
from .const import DOMAIN, ROUTER
|
||||
from .router import KeeneticRouter
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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."""
|
||||
router = config_entry.runtime_data
|
||||
router: KeeneticRouter = hass.data[DOMAIN][config_entry.entry_id][ROUTER]
|
||||
|
||||
tracked: set[str] = set()
|
||||
|
||||
|
||||
@@ -35,13 +35,11 @@ from .const import (
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type KeeneticConfigEntry = ConfigEntry[KeeneticRouter]
|
||||
|
||||
|
||||
class KeeneticRouter:
|
||||
"""Keenetic client Object."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config_entry: KeeneticConfigEntry) -> None:
|
||||
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
|
||||
"""Initialize the Client."""
|
||||
self.hass = hass
|
||||
self.config_entry = config_entry
|
||||
|
||||
@@ -6,18 +6,20 @@ import logging
|
||||
|
||||
from lacrosse_view import LaCrosse, LoginError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .coordinator import LaCrosseConfigEntry, LaCrosseUpdateCoordinator
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LaCrosseUpdateCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: LaCrosseConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up LaCrosse View from a config entry."""
|
||||
|
||||
api = LaCrosse(async_get_clientsession(hass))
|
||||
@@ -33,7 +35,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaCrosseConfigEntry) ->
|
||||
_LOGGER.debug("First refresh")
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
|
||||
"coordinator": coordinator,
|
||||
}
|
||||
|
||||
_LOGGER.debug("Setting up platforms")
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
@@ -41,6 +45,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaCrosseConfigEntry) ->
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: LaCrosseConfigEntry) -> 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
|
||||
|
||||
@@ -17,8 +17,6 @@ from .const import DOMAIN, SCAN_INTERVAL
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type LaCrosseConfigEntry = ConfigEntry[LaCrosseUpdateCoordinator]
|
||||
|
||||
|
||||
class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]):
|
||||
"""DataUpdateCoordinator for LaCrosse View."""
|
||||
@@ -29,12 +27,12 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]):
|
||||
id: str
|
||||
hass: HomeAssistant
|
||||
devices: list[Sensor] | None = None
|
||||
config_entry: LaCrosseConfigEntry
|
||||
config_entry: ConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: LaCrosseConfigEntry,
|
||||
entry: ConfigEntry,
|
||||
api: LaCrosse,
|
||||
) -> None:
|
||||
"""Initialize DataUpdateCoordinator for LaCrosse View."""
|
||||
|
||||
@@ -5,20 +5,25 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import LaCrosseConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LaCrosseUpdateCoordinator
|
||||
|
||||
TO_REDACT = {CONF_PASSWORD, CONF_USERNAME}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: LaCrosseConfigEntry
|
||||
hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator: LaCrosseUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][
|
||||
"coordinator"
|
||||
]
|
||||
|
||||
return {
|
||||
"entry": async_redact_data(entry.as_dict(), TO_REDACT),
|
||||
"coordinator_data": entry.runtime_data.data,
|
||||
"coordinator_data": coordinator.data,
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ from homeassistant.components.sensor import (
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
DEGREE,
|
||||
PERCENTAGE,
|
||||
@@ -31,7 +32,6 @@ from homeassistant.helpers.update_coordinator import (
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LaCrosseConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -159,14 +159,17 @@ UNIT_OF_MEASUREMENT_MAP = {
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: LaCrosseConfigEntry,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up LaCrosse View from a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
coordinator: DataUpdateCoordinator[list[Sensor]] = hass.data[DOMAIN][
|
||||
entry.entry_id
|
||||
]["coordinator"]
|
||||
sensors: list[Sensor] = coordinator.data
|
||||
|
||||
sensor_list = []
|
||||
for i, sensor in enumerate(coordinator.data):
|
||||
for i, sensor in enumerate(sensors):
|
||||
for field in sensor.sensor_field_names:
|
||||
description = SENSOR_DESCRIPTIONS.get(field)
|
||||
if description is None:
|
||||
|
||||
@@ -29,8 +29,6 @@ ATTR_ACTION = "action"
|
||||
ATTR_FULL_ID = "full_id"
|
||||
ATTR_UUID = "uuid"
|
||||
|
||||
type LutronConfigEntry = ConfigEntry[LutronData]
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class LutronData:
|
||||
@@ -46,9 +44,7 @@ class LutronData:
|
||||
switches: list[tuple[str, Output]]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, config_entry: LutronConfigEntry
|
||||
) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Set up the Lutron integration."""
|
||||
|
||||
host = config_entry.data[CONF_HOST]
|
||||
@@ -173,7 +169,7 @@ async def async_setup_entry(
|
||||
name="Main repeater",
|
||||
)
|
||||
|
||||
config_entry.runtime_data = entry_data
|
||||
hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = entry_data
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||
|
||||
@@ -226,6 +222,6 @@ def _async_check_device_identifiers(
|
||||
)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: LutronConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Clean up resources and entities associated with the integration."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pylutron import OccupancyGroup
|
||||
@@ -11,16 +12,19 @@ from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import LutronConfigEntry
|
||||
from . import DOMAIN, LutronData
|
||||
from .entity import LutronDevice
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: LutronConfigEntry,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Lutron binary_sensor platform.
|
||||
@@ -28,7 +32,7 @@ async def async_setup_entry(
|
||||
Adds occupancy groups from the Main Repeater associated with the
|
||||
config_entry as binary_sensor entities.
|
||||
"""
|
||||
entry_data = config_entry.runtime_data
|
||||
entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id]
|
||||
async_add_entities(
|
||||
[
|
||||
LutronOccupancySensor(area_name, device, entry_data.client)
|
||||
|
||||
@@ -9,7 +9,12 @@ from urllib.error import HTTPError
|
||||
from pylutron import Lutron
|
||||
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, CONF_USERNAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.selector import (
|
||||
@@ -18,7 +23,6 @@ from homeassistant.helpers.selector import (
|
||||
NumberSelectorMode,
|
||||
)
|
||||
|
||||
from . import LutronConfigEntry
|
||||
from .const import CONF_DEFAULT_DIMMER_LEVEL, DEFAULT_DIMMER_LEVEL, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -79,7 +83,7 @@ class LutronConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
config_entry: LutronConfigEntry,
|
||||
config_entry: ConfigEntry,
|
||||
) -> OptionsFlowHandler:
|
||||
"""Get the options flow for this handler."""
|
||||
return OptionsFlowHandler()
|
||||
|
||||
@@ -13,10 +13,11 @@ from homeassistant.components.cover import (
|
||||
CoverEntity,
|
||||
CoverEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import LutronConfigEntry
|
||||
from . import DOMAIN, LutronData
|
||||
from .entity import LutronDevice
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -24,7 +25,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: LutronConfigEntry,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Lutron cover platform.
|
||||
@@ -32,7 +33,7 @@ async def async_setup_entry(
|
||||
Adds shades from the Main Repeater associated with the config_entry as
|
||||
cover entities.
|
||||
"""
|
||||
entry_data = config_entry.runtime_data
|
||||
entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id]
|
||||
async_add_entities(
|
||||
[
|
||||
LutronCover(area_name, device, entry_data.client)
|
||||
|
||||
@@ -5,12 +5,13 @@ from enum import StrEnum
|
||||
from pylutron import Button, Keypad, Lutron, LutronEvent
|
||||
|
||||
from homeassistant.components.event import EventEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_ID
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from . import ATTR_ACTION, ATTR_FULL_ID, ATTR_UUID, LutronConfigEntry
|
||||
from . import ATTR_ACTION, ATTR_FULL_ID, ATTR_UUID, DOMAIN, LutronData
|
||||
from .entity import LutronKeypad
|
||||
|
||||
|
||||
@@ -31,11 +32,11 @@ LEGACY_EVENT_TYPES: dict[LutronEventType, str] = {
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: LutronConfigEntry,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Lutron event platform."""
|
||||
entry_data = config_entry.runtime_data
|
||||
entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id]
|
||||
|
||||
async_add_entities(
|
||||
LutronEventEntity(area_name, keypad, button, entry_data.client)
|
||||
|
||||
@@ -2,21 +2,25 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pylutron import Output
|
||||
|
||||
from homeassistant.components.fan import FanEntity, FanEntityFeature
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import LutronConfigEntry
|
||||
from . import DOMAIN, LutronData
|
||||
from .entity import LutronDevice
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: LutronConfigEntry,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Lutron fan platform.
|
||||
@@ -24,7 +28,7 @@ async def async_setup_entry(
|
||||
Adds fan controls from the Main Repeater associated with the config_entry as
|
||||
fan entities.
|
||||
"""
|
||||
entry_data = config_entry.runtime_data
|
||||
entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id]
|
||||
async_add_entities(
|
||||
[
|
||||
LutronFan(area_name, device, entry_data.client)
|
||||
|
||||
@@ -19,14 +19,14 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import LutronConfigEntry
|
||||
from . import DOMAIN, LutronData
|
||||
from .const import CONF_DEFAULT_DIMMER_LEVEL, DEFAULT_DIMMER_LEVEL
|
||||
from .entity import LutronDevice
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: LutronConfigEntry,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Lutron light platform.
|
||||
@@ -34,7 +34,7 @@ async def async_setup_entry(
|
||||
Adds dimmers from the Main Repeater associated with the config_entry as
|
||||
light entities.
|
||||
"""
|
||||
entry_data = config_entry.runtime_data
|
||||
entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id]
|
||||
|
||||
async_add_entities(
|
||||
(
|
||||
|
||||
@@ -7,16 +7,17 @@ from typing import Any
|
||||
from pylutron import Button, Keypad, Lutron
|
||||
|
||||
from homeassistant.components.scene import Scene
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import LutronConfigEntry
|
||||
from . import DOMAIN, LutronData
|
||||
from .entity import LutronKeypad
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: LutronConfigEntry,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Lutron scene platform.
|
||||
@@ -24,7 +25,7 @@ async def async_setup_entry(
|
||||
Adds scenes from the Main Repeater associated with the config_entry as
|
||||
scene entities.
|
||||
"""
|
||||
entry_data = config_entry.runtime_data
|
||||
entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id]
|
||||
|
||||
async_add_entities(
|
||||
LutronScene(area_name, keypad, device, entry_data.client)
|
||||
|
||||
@@ -8,16 +8,17 @@ from typing import Any
|
||||
from pylutron import Button, Keypad, Led, Lutron, Output
|
||||
|
||||
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 . import LutronConfigEntry
|
||||
from . import DOMAIN, LutronData
|
||||
from .entity import LutronDevice, LutronKeypad
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: LutronConfigEntry,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Lutron switch platform.
|
||||
@@ -25,7 +26,7 @@ async def async_setup_entry(
|
||||
Adds switches from the Main Repeater associated with the config_entry as
|
||||
switch entities.
|
||||
"""
|
||||
entry_data = config_entry.runtime_data
|
||||
entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id]
|
||||
entities: list[SwitchEntity] = []
|
||||
|
||||
# Add Lutron Switches
|
||||
|
||||
@@ -90,24 +90,6 @@ STEP_REAUTH_DATA_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
STEP_RECONFIGURE_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Exclusive(CONF_USERNAME, ATTR_CREDENTIALS): TextSelector(
|
||||
TextSelectorConfig(
|
||||
type=TextSelectorType.TEXT,
|
||||
autocomplete="username",
|
||||
),
|
||||
),
|
||||
vol.Optional(CONF_PASSWORD, default=""): TextSelector(
|
||||
TextSelectorConfig(
|
||||
type=TextSelectorType.PASSWORD,
|
||||
autocomplete="current-password",
|
||||
),
|
||||
),
|
||||
vol.Exclusive(CONF_TOKEN, ATTR_CREDENTIALS): str,
|
||||
}
|
||||
)
|
||||
|
||||
STEP_USER_TOPIC_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_TOPIC): str,
|
||||
@@ -262,103 +244,6 @@ class NtfyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
description_placeholders={CONF_USERNAME: entry.data[CONF_USERNAME]},
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfigure flow for ntfy."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
entry = self._get_reconfigure_entry()
|
||||
|
||||
if user_input is not None:
|
||||
session = async_get_clientsession(self.hass)
|
||||
if token := user_input.get(CONF_TOKEN):
|
||||
ntfy = Ntfy(
|
||||
entry.data[CONF_URL],
|
||||
session,
|
||||
token=user_input[CONF_TOKEN],
|
||||
)
|
||||
else:
|
||||
ntfy = Ntfy(
|
||||
entry.data[CONF_URL],
|
||||
session,
|
||||
username=user_input.get(CONF_USERNAME, entry.data[CONF_USERNAME]),
|
||||
password=user_input[CONF_PASSWORD],
|
||||
)
|
||||
|
||||
try:
|
||||
account = await ntfy.account()
|
||||
if not token:
|
||||
token = (await ntfy.generate_token("Home Assistant")).token
|
||||
except NtfyUnauthorizedAuthenticationError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except NtfyHTTPError as e:
|
||||
_LOGGER.debug("Error %s: %s [%s]", e.code, e.error, e.link)
|
||||
errors["base"] = "cannot_connect"
|
||||
except NtfyException:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
if entry.data[CONF_USERNAME]:
|
||||
if entry.data[CONF_USERNAME] != account.username:
|
||||
return self.async_abort(
|
||||
reason="account_mismatch",
|
||||
description_placeholders={
|
||||
CONF_USERNAME: entry.data[CONF_USERNAME],
|
||||
"wrong_username": account.username,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
entry,
|
||||
data_updates={CONF_TOKEN: token},
|
||||
)
|
||||
self._async_abort_entries_match(
|
||||
{
|
||||
CONF_URL: entry.data[CONF_URL],
|
||||
CONF_USERNAME: account.username,
|
||||
}
|
||||
)
|
||||
return self.async_update_reload_and_abort(
|
||||
entry,
|
||||
data_updates={
|
||||
CONF_USERNAME: account.username,
|
||||
CONF_TOKEN: token,
|
||||
},
|
||||
)
|
||||
if entry.data[CONF_USERNAME]:
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure_user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
data_schema=STEP_REAUTH_DATA_SCHEMA,
|
||||
suggested_values=user_input,
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
CONF_NAME: entry.title,
|
||||
CONF_USERNAME: entry.data[CONF_USERNAME],
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
data_schema=STEP_RECONFIGURE_DATA_SCHEMA,
|
||||
suggested_values=user_input,
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders={CONF_NAME: entry.title},
|
||||
)
|
||||
|
||||
async def async_step_reconfigure_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfigure flow for authenticated ntfy entry."""
|
||||
|
||||
return await self.async_step_reconfigure(user_input)
|
||||
|
||||
|
||||
class TopicSubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Handle subentry flow for adding and modifying a topic."""
|
||||
|
||||
@@ -72,7 +72,7 @@ rules:
|
||||
comment: the notify entity uses the device name as entity name, no translation required
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: the integration has no repairs
|
||||
|
||||
@@ -39,33 +39,7 @@
|
||||
},
|
||||
"data_description": {
|
||||
"password": "Enter the password corresponding to the aforementioned username to automatically create an access token",
|
||||
"token": "Enter a new access token. To create a new access token navigate to Account → Access tokens and select 'Create access token'"
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Configuration for {name}",
|
||||
"description": "You can either log in with your **ntfy** username and password, and Home Assistant will automatically create an access token to authenticate with **ntfy**, or you can provide an access token directly",
|
||||
"data": {
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"token": "[%key:common::config_flow::data::access_token%]"
|
||||
},
|
||||
"data_description": {
|
||||
"username": "[%key:component::ntfy::config::step::user::sections::auth::data_description::username%]",
|
||||
"password": "[%key:component::ntfy::config::step::user::sections::auth::data_description::password%]",
|
||||
"token": "Enter a new or existing access token. To create a new access token navigate to Account → Access tokens and select 'Create access token'"
|
||||
}
|
||||
},
|
||||
"reconfigure_user": {
|
||||
"title": "[%key:component::ntfy::config::step::reconfigure::title%]",
|
||||
"description": "Enter the password for **{username}** below. Home Assistant will automatically create a new access token to authenticate with **ntfy**. You can also directly provide a valid access token",
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"token": "[%key:common::config_flow::data::access_token%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "[%key:component::ntfy::config::step::reauth_confirm::data_description::password%]",
|
||||
"token": "[%key:component::ntfy::config::step::reconfigure::data_description::token%]"
|
||||
"token": "Enter a new access token. To create a new access token navigate to Account → Access tokens and click create access token"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -77,8 +51,7 @@
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"account_mismatch": "The provided access token corresponds to the account {wrong_username}. Please re-authenticate with the account **{username}**",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
"account_mismatch": "The provided access token corresponds to the account {wrong_username}. Please re-authenticate with the account **{username}**"
|
||||
}
|
||||
},
|
||||
"config_subentries": {
|
||||
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
import base64
|
||||
from mimetypes import guess_file_type
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
|
||||
import openai
|
||||
from openai.types.images_response import ImagesResponse
|
||||
@@ -20,7 +19,7 @@ from openai.types.responses import (
|
||||
)
|
||||
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,
|
||||
@@ -33,11 +32,7 @@ from homeassistant.exceptions import (
|
||||
HomeAssistantError,
|
||||
ServiceValidationError,
|
||||
)
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
entity_registry as er,
|
||||
selector,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv, selector
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
@@ -49,11 +44,8 @@ from .const import (
|
||||
CONF_REASONING_EFFORT,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_TOP_P,
|
||||
DEFAULT_AI_TASK_NAME,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
RECOMMENDED_AI_TASK_OPTIONS,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
RECOMMENDED_MAX_TOKENS,
|
||||
RECOMMENDED_REASONING_EFFORT,
|
||||
@@ -64,10 +56,7 @@ from .const import (
|
||||
SERVICE_GENERATE_IMAGE = "generate_image"
|
||||
SERVICE_GENERATE_CONTENT = "generate_content"
|
||||
|
||||
PLATFORMS = (
|
||||
Platform.AI_TASK,
|
||||
Platform.CONVERSATION,
|
||||
)
|
||||
PLATFORMS = (Platform.CONVERSATION,)
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
type OpenAIConfigEntry = ConfigEntry[openai.AsyncClient]
|
||||
@@ -129,21 +118,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
translation_placeholders={"config_entry": entry_id},
|
||||
)
|
||||
|
||||
# Get first conversation subentry for options
|
||||
conversation_subentry = next(
|
||||
(
|
||||
sub
|
||||
for sub in entry.subentries.values()
|
||||
if sub.subentry_type == "conversation"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not conversation_subentry:
|
||||
raise HomeAssistantError("No conversation configuration found")
|
||||
|
||||
model: str = conversation_subentry.data.get(
|
||||
CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL
|
||||
)
|
||||
model: str = entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
|
||||
client: openai.AsyncClient = entry.runtime_data
|
||||
|
||||
content: ResponseInputMessageContentListParam = [
|
||||
@@ -194,11 +169,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
model_args = {
|
||||
"model": model,
|
||||
"input": messages,
|
||||
"max_output_tokens": conversation_subentry.data.get(
|
||||
"max_output_tokens": entry.options.get(
|
||||
CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS
|
||||
),
|
||||
"top_p": conversation_subentry.data.get(CONF_TOP_P, RECOMMENDED_TOP_P),
|
||||
"temperature": conversation_subentry.data.get(
|
||||
"top_p": entry.options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
|
||||
"temperature": entry.options.get(
|
||||
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
|
||||
),
|
||||
"user": call.context.user_id,
|
||||
@@ -207,7 +182,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
if model.startswith("o"):
|
||||
model_args["reasoning"] = {
|
||||
"effort": conversation_subentry.data.get(
|
||||
"effort": entry.options.get(
|
||||
CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT
|
||||
)
|
||||
}
|
||||
@@ -294,49 +269,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> bo
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload OpenAI."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> 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,62 +0,0 @@
|
||||
"""AI Task integration for OpenAI Conversation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components import ai_task, conversation
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import OpenAIConfigEntry
|
||||
from .const import DEFAULT_AI_TASK_NAME, LOGGER
|
||||
from .entity import OpenAILLMBaseEntity
|
||||
|
||||
ERROR_GETTING_RESPONSE = "Sorry, I had a problem getting a response from OpenAI."
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up AI Task entities."""
|
||||
for subentry in config_entry.subentries.values():
|
||||
if subentry.subentry_type != "ai_task":
|
||||
continue
|
||||
|
||||
async_add_entities(
|
||||
[OpenAILLMTaskEntity(config_entry, subentry)],
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
)
|
||||
|
||||
|
||||
class OpenAILLMTaskEntity(ai_task.AITaskEntity, OpenAILLMBaseEntity):
|
||||
"""OpenAI AI Task entity."""
|
||||
|
||||
_attr_supported_features = ai_task.AITaskEntityFeature.GENERATE_TEXT
|
||||
|
||||
def __init__(self, entry: OpenAIConfigEntry, subentry: ConfigSubentry) -> None:
|
||||
"""Initialize the agent."""
|
||||
super().__init__(entry, subentry)
|
||||
self._attr_name = subentry.title or DEFAULT_AI_TASK_NAME
|
||||
|
||||
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 "",
|
||||
)
|
||||
@@ -15,17 +15,15 @@ from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
ConfigSubentryFlow,
|
||||
SubentryFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_LATITUDE,
|
||||
ATTR_LONGITUDE,
|
||||
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.httpx_client import get_async_client
|
||||
from homeassistant.helpers.selector import (
|
||||
@@ -54,12 +52,8 @@ from .const import (
|
||||
CONF_WEB_SEARCH_REGION,
|
||||
CONF_WEB_SEARCH_TIMEZONE,
|
||||
CONF_WEB_SEARCH_USER_LOCATION,
|
||||
DEFAULT_AI_TASK_NAME,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
DOMAIN,
|
||||
RECOMMENDED_AI_TASK_OPTIONS,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
RECOMMENDED_CONVERSATION_OPTIONS,
|
||||
RECOMMENDED_MAX_TOKENS,
|
||||
RECOMMENDED_REASONING_EFFORT,
|
||||
RECOMMENDED_TEMPERATURE,
|
||||
@@ -79,6 +73,12 @@ STEP_USER_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(hass: HomeAssistant, data: dict[str, Any]) -> None:
|
||||
"""Validate the user input allows us to connect.
|
||||
@@ -94,7 +94,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
|
||||
class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for OpenAI Conversation."""
|
||||
|
||||
VERSION = 2
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -120,67 +120,31 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_create_entry(
|
||||
title="ChatGPT",
|
||||
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="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
@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 OpenAIOptionsFlow(config_entry)
|
||||
|
||||
|
||||
class LLMSubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Flow for managing conversation subentries."""
|
||||
class OpenAIOptionsFlow(OptionsFlow):
|
||||
"""OpenAI config flow options handler."""
|
||||
|
||||
last_rendered_recommended = False
|
||||
is_new: bool
|
||||
options: dict[str, Any]
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Add a subentry."""
|
||||
self.is_new = True
|
||||
if self._subentry_type == "ai_task":
|
||||
self.options = RECOMMENDED_AI_TASK_OPTIONS.copy()
|
||||
else:
|
||||
self.options = RECOMMENDED_CONVERSATION_OPTIONS.copy()
|
||||
return await self.async_step_init()
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Handle reconfiguration of a subentry."""
|
||||
self.is_new = False
|
||||
self.options = self._get_reconfigure_subentry().data.copy()
|
||||
return await self.async_step_init()
|
||||
def __init__(self, config_entry: ConfigEntry) -> None:
|
||||
"""Initialize options flow."""
|
||||
self.options = config_entry.options.copy()
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
) -> ConfigFlowResult:
|
||||
"""Manage initial options."""
|
||||
options = self.options
|
||||
|
||||
@@ -196,53 +160,25 @@ class LLMSubentryFlowHandler(ConfigSubentryFlow):
|
||||
):
|
||||
options[CONF_LLM_HASS_API] = [suggested_llm_apis]
|
||||
|
||||
step_schema: VolDictType = {}
|
||||
|
||||
if self.is_new:
|
||||
if CONF_NAME in options:
|
||||
default_name = options[CONF_NAME]
|
||||
elif self._subentry_type == "ai_task":
|
||||
default_name = DEFAULT_AI_TASK_NAME
|
||||
else:
|
||||
default_name = DEFAULT_CONVERSATION_NAME
|
||||
step_schema[vol.Required(CONF_NAME, default=default_name)] = str
|
||||
|
||||
if self._subentry_type == "conversation":
|
||||
step_schema.update(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_PROMPT,
|
||||
description={
|
||||
"suggested_value": options.get(
|
||||
CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT
|
||||
)
|
||||
},
|
||||
): TemplateSelector(),
|
||||
vol.Optional(CONF_LLM_HASS_API): SelectSelector(
|
||||
SelectSelectorConfig(options=hass_apis, multiple=True)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
step_schema[
|
||||
vol.Required(CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False))
|
||||
] = bool
|
||||
step_schema: VolDictType = {
|
||||
vol.Optional(
|
||||
CONF_PROMPT,
|
||||
description={"suggested_value": llm.DEFAULT_INSTRUCTIONS_PROMPT},
|
||||
): TemplateSelector(),
|
||||
vol.Optional(CONF_LLM_HASS_API): SelectSelector(
|
||||
SelectSelectorConfig(options=hass_apis, multiple=True)
|
||||
),
|
||||
vol.Required(
|
||||
CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)
|
||||
): bool,
|
||||
}
|
||||
|
||||
if user_input is not None:
|
||||
if not user_input.get(CONF_LLM_HASS_API):
|
||||
user_input.pop(CONF_LLM_HASS_API, None)
|
||||
|
||||
if user_input[CONF_RECOMMENDED]:
|
||||
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,
|
||||
)
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
|
||||
options.update(user_input)
|
||||
if CONF_LLM_HASS_API in options and CONF_LLM_HASS_API not in user_input:
|
||||
@@ -258,7 +194,7 @@ class LLMSubentryFlowHandler(ConfigSubentryFlow):
|
||||
|
||||
async def async_step_advanced(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
) -> ConfigFlowResult:
|
||||
"""Manage advanced options."""
|
||||
options = self.options
|
||||
errors: dict[str, str] = {}
|
||||
@@ -300,7 +236,7 @@ class LLMSubentryFlowHandler(ConfigSubentryFlow):
|
||||
|
||||
async def async_step_model(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
) -> ConfigFlowResult:
|
||||
"""Manage model-specific options."""
|
||||
options = self.options
|
||||
errors: dict[str, str] = {}
|
||||
@@ -367,16 +303,7 @@ class LLMSubentryFlowHandler(ConfigSubentryFlow):
|
||||
}
|
||||
|
||||
if not step_schema:
|
||||
if self.is_new:
|
||||
return self.async_create_entry(
|
||||
title=options.pop(CONF_NAME, DEFAULT_CONVERSATION_NAME),
|
||||
data=options,
|
||||
)
|
||||
return self.async_update_and_abort(
|
||||
self._get_entry(),
|
||||
self._get_reconfigure_subentry(),
|
||||
data=options,
|
||||
)
|
||||
return self.async_create_entry(title="", data=options)
|
||||
|
||||
if user_input is not None:
|
||||
if user_input.get(CONF_WEB_SEARCH):
|
||||
@@ -389,16 +316,7 @@ class LLMSubentryFlowHandler(ConfigSubentryFlow):
|
||||
options.pop(CONF_WEB_SEARCH_TIMEZONE, None)
|
||||
|
||||
options.update(user_input)
|
||||
if self.is_new:
|
||||
return self.async_create_entry(
|
||||
title=options.pop(CONF_NAME, DEFAULT_CONVERSATION_NAME),
|
||||
data=options,
|
||||
)
|
||||
return self.async_update_and_abort(
|
||||
self._get_entry(),
|
||||
self._get_reconfigure_subentry(),
|
||||
data=options,
|
||||
)
|
||||
return self.async_create_entry(title="", data=options)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="model",
|
||||
@@ -414,7 +332,7 @@ class LLMSubentryFlowHandler(ConfigSubentryFlow):
|
||||
zone_home = self.hass.states.get(ENTITY_ID_HOME)
|
||||
if zone_home is not None:
|
||||
client = openai.AsyncOpenAI(
|
||||
api_key=self._get_entry().data[CONF_API_KEY],
|
||||
api_key=self.config_entry.data[CONF_API_KEY],
|
||||
http_client=get_async_client(self.hass),
|
||||
)
|
||||
location_schema = vol.Schema(
|
||||
|
||||
@@ -2,15 +2,9 @@
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.const import CONF_LLM_HASS_API
|
||||
from homeassistant.helpers import llm
|
||||
|
||||
DOMAIN = "openai_conversation"
|
||||
LOGGER: logging.Logger = logging.getLogger(__package__)
|
||||
|
||||
DEFAULT_CONVERSATION_NAME = "OpenAI Conversation"
|
||||
DEFAULT_AI_TASK_NAME = "OpenAI AI Task"
|
||||
|
||||
CONF_CHAT_MODEL = "chat_model"
|
||||
CONF_FILENAMES = "filenames"
|
||||
CONF_MAX_TOKENS = "max_tokens"
|
||||
@@ -36,16 +30,6 @@ RECOMMENDED_WEB_SEARCH = False
|
||||
RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE = "medium"
|
||||
RECOMMENDED_WEB_SEARCH_USER_LOCATION = False
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
UNSUPPORTED_MODELS: list[str] = [
|
||||
"o1-mini",
|
||||
"o1-mini-2024-09-12",
|
||||
|
||||
@@ -1,17 +1,73 @@
|
||||
"""Conversation support for OpenAI."""
|
||||
|
||||
from typing import Literal
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
import json
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
import openai
|
||||
from openai._streaming import AsyncStream
|
||||
from openai.types.responses import (
|
||||
EasyInputMessageParam,
|
||||
FunctionToolParam,
|
||||
ResponseCompletedEvent,
|
||||
ResponseErrorEvent,
|
||||
ResponseFailedEvent,
|
||||
ResponseFunctionCallArgumentsDeltaEvent,
|
||||
ResponseFunctionCallArgumentsDoneEvent,
|
||||
ResponseFunctionToolCall,
|
||||
ResponseFunctionToolCallParam,
|
||||
ResponseIncompleteEvent,
|
||||
ResponseInputParam,
|
||||
ResponseOutputItemAddedEvent,
|
||||
ResponseOutputItemDoneEvent,
|
||||
ResponseOutputMessage,
|
||||
ResponseOutputMessageParam,
|
||||
ResponseReasoningItem,
|
||||
ResponseReasoningItemParam,
|
||||
ResponseStreamEvent,
|
||||
ResponseTextDeltaEvent,
|
||||
ToolParam,
|
||||
WebSearchToolParam,
|
||||
)
|
||||
from openai.types.responses.response_input_param import FunctionCallOutput
|
||||
from openai.types.responses.web_search_tool_param import UserLocation
|
||||
from voluptuous_openapi import convert
|
||||
|
||||
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.helpers import intent
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, intent, llm
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import OpenAIConfigEntry
|
||||
from .const import CONF_PROMPT, DEFAULT_CONVERSATION_NAME, DOMAIN
|
||||
from .entity import OpenAILLMBaseEntity
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
CONF_MAX_TOKENS,
|
||||
CONF_PROMPT,
|
||||
CONF_REASONING_EFFORT,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_TOP_P,
|
||||
CONF_WEB_SEARCH,
|
||||
CONF_WEB_SEARCH_CITY,
|
||||
CONF_WEB_SEARCH_CONTEXT_SIZE,
|
||||
CONF_WEB_SEARCH_COUNTRY,
|
||||
CONF_WEB_SEARCH_REGION,
|
||||
CONF_WEB_SEARCH_TIMEZONE,
|
||||
CONF_WEB_SEARCH_USER_LOCATION,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
RECOMMENDED_MAX_TOKENS,
|
||||
RECOMMENDED_REASONING_EFFORT,
|
||||
RECOMMENDED_TEMPERATURE,
|
||||
RECOMMENDED_TOP_P,
|
||||
RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE,
|
||||
)
|
||||
|
||||
# Max number of back and forth with the LLM to generate a response
|
||||
MAX_TOOL_ITERATIONS = 10
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -20,30 +76,175 @@ 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
|
||||
agent = OpenAIConversationEntity(config_entry)
|
||||
async_add_entities([agent])
|
||||
|
||||
async_add_entities(
|
||||
[OpenAIConversationEntity(config_entry, subentry)],
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
|
||||
def _format_tool(
|
||||
tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
|
||||
) -> FunctionToolParam:
|
||||
"""Format tool specification."""
|
||||
return FunctionToolParam(
|
||||
type="function",
|
||||
name=tool.name,
|
||||
parameters=convert(tool.parameters, custom_serializer=custom_serializer),
|
||||
description=tool.description,
|
||||
strict=False,
|
||||
)
|
||||
|
||||
|
||||
def _convert_content_to_param(
|
||||
content: conversation.Content,
|
||||
) -> ResponseInputParam:
|
||||
"""Convert any native chat message for this agent to the native format."""
|
||||
messages: ResponseInputParam = []
|
||||
if isinstance(content, conversation.ToolResultContent):
|
||||
return [
|
||||
FunctionCallOutput(
|
||||
type="function_call_output",
|
||||
call_id=content.tool_call_id,
|
||||
output=json.dumps(content.tool_result),
|
||||
)
|
||||
]
|
||||
|
||||
if content.content:
|
||||
role: Literal["user", "assistant", "system", "developer"] = content.role
|
||||
if role == "system":
|
||||
role = "developer"
|
||||
messages.append(
|
||||
EasyInputMessageParam(type="message", role=role, content=content.content)
|
||||
)
|
||||
|
||||
if isinstance(content, conversation.AssistantContent) and content.tool_calls:
|
||||
messages.extend(
|
||||
ResponseFunctionToolCallParam(
|
||||
type="function_call",
|
||||
name=tool_call.tool_name,
|
||||
arguments=json.dumps(tool_call.tool_args),
|
||||
call_id=tool_call.id,
|
||||
)
|
||||
for tool_call in content.tool_calls
|
||||
)
|
||||
return messages
|
||||
|
||||
|
||||
async def _transform_stream(
|
||||
chat_log: conversation.ChatLog,
|
||||
result: AsyncStream[ResponseStreamEvent],
|
||||
messages: ResponseInputParam,
|
||||
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
|
||||
"""Transform an OpenAI delta stream into HA format."""
|
||||
async for event in result:
|
||||
LOGGER.debug("Received event: %s", event)
|
||||
|
||||
if isinstance(event, ResponseOutputItemAddedEvent):
|
||||
if isinstance(event.item, ResponseOutputMessage):
|
||||
yield {"role": event.item.role}
|
||||
elif isinstance(event.item, ResponseFunctionToolCall):
|
||||
# OpenAI has tool calls as individual events
|
||||
# while HA puts tool calls inside the assistant message.
|
||||
# We turn them into individual assistant content for HA
|
||||
# to ensure that tools are called as soon as possible.
|
||||
yield {"role": "assistant"}
|
||||
current_tool_call = event.item
|
||||
elif isinstance(event, ResponseOutputItemDoneEvent):
|
||||
item = event.item.model_dump()
|
||||
item.pop("status", None)
|
||||
if isinstance(event.item, ResponseReasoningItem):
|
||||
messages.append(cast(ResponseReasoningItemParam, item))
|
||||
elif isinstance(event.item, ResponseOutputMessage):
|
||||
messages.append(cast(ResponseOutputMessageParam, item))
|
||||
elif isinstance(event.item, ResponseFunctionToolCall):
|
||||
messages.append(cast(ResponseFunctionToolCallParam, item))
|
||||
elif isinstance(event, ResponseTextDeltaEvent):
|
||||
yield {"content": event.delta}
|
||||
elif isinstance(event, ResponseFunctionCallArgumentsDeltaEvent):
|
||||
current_tool_call.arguments += event.delta
|
||||
elif isinstance(event, ResponseFunctionCallArgumentsDoneEvent):
|
||||
current_tool_call.status = "completed"
|
||||
yield {
|
||||
"tool_calls": [
|
||||
llm.ToolInput(
|
||||
id=current_tool_call.call_id,
|
||||
tool_name=current_tool_call.name,
|
||||
tool_args=json.loads(current_tool_call.arguments),
|
||||
)
|
||||
]
|
||||
}
|
||||
elif isinstance(event, ResponseCompletedEvent):
|
||||
if event.response.usage is not None:
|
||||
chat_log.async_trace(
|
||||
{
|
||||
"stats": {
|
||||
"input_tokens": event.response.usage.input_tokens,
|
||||
"output_tokens": event.response.usage.output_tokens,
|
||||
}
|
||||
}
|
||||
)
|
||||
elif isinstance(event, ResponseIncompleteEvent):
|
||||
if event.response.usage is not None:
|
||||
chat_log.async_trace(
|
||||
{
|
||||
"stats": {
|
||||
"input_tokens": event.response.usage.input_tokens,
|
||||
"output_tokens": event.response.usage.output_tokens,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (
|
||||
event.response.incomplete_details
|
||||
and event.response.incomplete_details.reason
|
||||
):
|
||||
reason: str = event.response.incomplete_details.reason
|
||||
else:
|
||||
reason = "unknown reason"
|
||||
|
||||
if reason == "max_output_tokens":
|
||||
reason = "max output tokens reached"
|
||||
elif reason == "content_filter":
|
||||
reason = "content filter triggered"
|
||||
|
||||
raise HomeAssistantError(f"OpenAI response incomplete: {reason}")
|
||||
elif isinstance(event, ResponseFailedEvent):
|
||||
if event.response.usage is not None:
|
||||
chat_log.async_trace(
|
||||
{
|
||||
"stats": {
|
||||
"input_tokens": event.response.usage.input_tokens,
|
||||
"output_tokens": event.response.usage.output_tokens,
|
||||
}
|
||||
}
|
||||
)
|
||||
reason = "unknown reason"
|
||||
if event.response.error is not None:
|
||||
reason = event.response.error.message
|
||||
raise HomeAssistantError(f"OpenAI response failed: {reason}")
|
||||
elif isinstance(event, ResponseErrorEvent):
|
||||
raise HomeAssistantError(f"OpenAI response error: {event.message}")
|
||||
|
||||
|
||||
class OpenAIConversationEntity(
|
||||
conversation.ConversationEntity,
|
||||
conversation.AbstractConversationAgent,
|
||||
OpenAILLMBaseEntity,
|
||||
conversation.ConversationEntity, conversation.AbstractConversationAgent
|
||||
):
|
||||
"""OpenAI conversation agent."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_supports_streaming = True
|
||||
|
||||
def __init__(self, entry: OpenAIConfigEntry, subentry: ConfigSubentry) -> None:
|
||||
def __init__(self, entry: OpenAIConfigEntry) -> None:
|
||||
"""Initialize the agent."""
|
||||
super().__init__(entry, subentry)
|
||||
self._attr_name = subentry.title or DEFAULT_CONVERSATION_NAME
|
||||
if self.subentry.data.get(CONF_LLM_HASS_API):
|
||||
self.entry = entry
|
||||
self._attr_unique_id = entry.entry_id
|
||||
self._attr_device_info = dr.DeviceInfo(
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
name=entry.title,
|
||||
manufacturer="OpenAI",
|
||||
model="ChatGPT",
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
if self.entry.options.get(CONF_LLM_HASS_API):
|
||||
self._attr_supported_features = (
|
||||
conversation.ConversationEntityFeature.CONTROL
|
||||
)
|
||||
@@ -75,7 +276,7 @@ class OpenAIConversationEntity(
|
||||
chat_log: conversation.ChatLog,
|
||||
) -> conversation.ConversationResult:
|
||||
"""Process the user input and call the API."""
|
||||
options = self.subentry.data
|
||||
options = self.entry.options
|
||||
|
||||
try:
|
||||
await chat_log.async_provide_llm_data(
|
||||
@@ -98,6 +299,91 @@ class OpenAIConversationEntity(
|
||||
continue_conversation=chat_log.continue_conversation,
|
||||
)
|
||||
|
||||
async def _async_handle_chat_log(
|
||||
self,
|
||||
chat_log: conversation.ChatLog,
|
||||
) -> None:
|
||||
"""Generate an answer for the chat log."""
|
||||
options = self.entry.options
|
||||
|
||||
tools: list[ToolParam] | None = None
|
||||
if chat_log.llm_api:
|
||||
tools = [
|
||||
_format_tool(tool, chat_log.llm_api.custom_serializer)
|
||||
for tool in chat_log.llm_api.tools
|
||||
]
|
||||
|
||||
if options.get(CONF_WEB_SEARCH):
|
||||
web_search = WebSearchToolParam(
|
||||
type="web_search_preview",
|
||||
search_context_size=options.get(
|
||||
CONF_WEB_SEARCH_CONTEXT_SIZE, RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE
|
||||
),
|
||||
)
|
||||
if options.get(CONF_WEB_SEARCH_USER_LOCATION):
|
||||
web_search["user_location"] = UserLocation(
|
||||
type="approximate",
|
||||
city=options.get(CONF_WEB_SEARCH_CITY, ""),
|
||||
region=options.get(CONF_WEB_SEARCH_REGION, ""),
|
||||
country=options.get(CONF_WEB_SEARCH_COUNTRY, ""),
|
||||
timezone=options.get(CONF_WEB_SEARCH_TIMEZONE, ""),
|
||||
)
|
||||
if tools is None:
|
||||
tools = []
|
||||
tools.append(web_search)
|
||||
|
||||
model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
|
||||
messages = [
|
||||
m
|
||||
for content in chat_log.content
|
||||
for m in _convert_content_to_param(content)
|
||||
]
|
||||
|
||||
client = self.entry.runtime_data
|
||||
|
||||
# To prevent infinite loops, we limit the number of iterations
|
||||
for _iteration in range(MAX_TOOL_ITERATIONS):
|
||||
model_args = {
|
||||
"model": model,
|
||||
"input": messages,
|
||||
"max_output_tokens": options.get(
|
||||
CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS
|
||||
),
|
||||
"top_p": options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
|
||||
"temperature": options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE),
|
||||
"user": chat_log.conversation_id,
|
||||
"stream": True,
|
||||
}
|
||||
if tools:
|
||||
model_args["tools"] = tools
|
||||
|
||||
if model.startswith("o"):
|
||||
model_args["reasoning"] = {
|
||||
"effort": options.get(
|
||||
CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT
|
||||
)
|
||||
}
|
||||
else:
|
||||
model_args["store"] = False
|
||||
|
||||
try:
|
||||
result = await client.responses.create(**model_args)
|
||||
except openai.RateLimitError as err:
|
||||
LOGGER.error("Rate limited by OpenAI: %s", err)
|
||||
raise HomeAssistantError("Rate limited or insufficient funds") from err
|
||||
except openai.OpenAIError as err:
|
||||
LOGGER.error("Error talking to OpenAI: %s", err)
|
||||
raise HomeAssistantError("Error talking to OpenAI") from err
|
||||
|
||||
async for content in chat_log.async_add_delta_content_stream(
|
||||
self.entity_id, _transform_stream(chat_log, result, messages)
|
||||
):
|
||||
if not isinstance(content, conversation.AssistantContent):
|
||||
messages.extend(_convert_content_to_param(content))
|
||||
|
||||
if not chat_log.unresponded_tool_results:
|
||||
break
|
||||
|
||||
async def _async_entry_update_listener(
|
||||
self, hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> None:
|
||||
|
||||
@@ -1,313 +0,0 @@
|
||||
"""Base class for OpenAI Conversation entities."""
|
||||
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
import json
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
import openai
|
||||
from openai._streaming import AsyncStream
|
||||
from openai.types.responses import (
|
||||
EasyInputMessageParam,
|
||||
FunctionToolParam,
|
||||
ResponseCompletedEvent,
|
||||
ResponseErrorEvent,
|
||||
ResponseFailedEvent,
|
||||
ResponseFunctionCallArgumentsDeltaEvent,
|
||||
ResponseFunctionCallArgumentsDoneEvent,
|
||||
ResponseFunctionToolCall,
|
||||
ResponseFunctionToolCallParam,
|
||||
ResponseIncompleteEvent,
|
||||
ResponseInputParam,
|
||||
ResponseOutputItemAddedEvent,
|
||||
ResponseOutputItemDoneEvent,
|
||||
ResponseOutputMessage,
|
||||
ResponseOutputMessageParam,
|
||||
ResponseReasoningItem,
|
||||
ResponseReasoningItemParam,
|
||||
ResponseStreamEvent,
|
||||
ResponseTextDeltaEvent,
|
||||
ToolParam,
|
||||
WebSearchToolParam,
|
||||
)
|
||||
from openai.types.responses.response_input_param import FunctionCallOutput
|
||||
from openai.types.responses.web_search_tool_param import UserLocation
|
||||
from voluptuous_openapi import convert
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, llm
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from . import OpenAIConfigEntry
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
CONF_MAX_TOKENS,
|
||||
CONF_REASONING_EFFORT,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_TOP_P,
|
||||
CONF_WEB_SEARCH,
|
||||
CONF_WEB_SEARCH_CITY,
|
||||
CONF_WEB_SEARCH_CONTEXT_SIZE,
|
||||
CONF_WEB_SEARCH_COUNTRY,
|
||||
CONF_WEB_SEARCH_REGION,
|
||||
CONF_WEB_SEARCH_TIMEZONE,
|
||||
CONF_WEB_SEARCH_USER_LOCATION,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
RECOMMENDED_MAX_TOKENS,
|
||||
RECOMMENDED_REASONING_EFFORT,
|
||||
RECOMMENDED_TEMPERATURE,
|
||||
RECOMMENDED_TOP_P,
|
||||
RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE,
|
||||
)
|
||||
|
||||
# Max number of back and forth with the LLM to generate a response
|
||||
MAX_TOOL_ITERATIONS = 10
|
||||
|
||||
|
||||
def _format_tool(
|
||||
tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
|
||||
) -> FunctionToolParam:
|
||||
"""Format tool specification."""
|
||||
return FunctionToolParam(
|
||||
type="function",
|
||||
name=tool.name,
|
||||
parameters=convert(tool.parameters, custom_serializer=custom_serializer),
|
||||
description=tool.description,
|
||||
strict=False,
|
||||
)
|
||||
|
||||
|
||||
def _convert_content_to_param(
|
||||
content: conversation.Content,
|
||||
) -> ResponseInputParam:
|
||||
"""Convert any native chat message for this agent to the native format."""
|
||||
messages: ResponseInputParam = []
|
||||
if isinstance(content, conversation.ToolResultContent):
|
||||
return [
|
||||
FunctionCallOutput(
|
||||
type="function_call_output",
|
||||
call_id=content.tool_call_id,
|
||||
output=json.dumps(content.tool_result),
|
||||
)
|
||||
]
|
||||
|
||||
if content.content:
|
||||
role: Literal["user", "assistant", "system", "developer"] = content.role
|
||||
if role == "system":
|
||||
role = "developer"
|
||||
messages.append(
|
||||
EasyInputMessageParam(type="message", role=role, content=content.content)
|
||||
)
|
||||
|
||||
if isinstance(content, conversation.AssistantContent) and content.tool_calls:
|
||||
messages.extend(
|
||||
ResponseFunctionToolCallParam(
|
||||
type="function_call",
|
||||
name=tool_call.tool_name,
|
||||
arguments=json.dumps(tool_call.tool_args),
|
||||
call_id=tool_call.id,
|
||||
)
|
||||
for tool_call in content.tool_calls
|
||||
)
|
||||
return messages
|
||||
|
||||
|
||||
async def _transform_stream(
|
||||
chat_log: conversation.ChatLog,
|
||||
result: AsyncStream[ResponseStreamEvent],
|
||||
messages: ResponseInputParam,
|
||||
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
|
||||
"""Transform an OpenAI delta stream into HA format."""
|
||||
async for event in result:
|
||||
LOGGER.debug("Received event: %s", event)
|
||||
|
||||
if isinstance(event, ResponseOutputItemAddedEvent):
|
||||
if isinstance(event.item, ResponseOutputMessage):
|
||||
yield {"role": event.item.role}
|
||||
elif isinstance(event.item, ResponseFunctionToolCall):
|
||||
# OpenAI has tool calls as individual events
|
||||
# while HA puts tool calls inside the assistant message.
|
||||
# We turn them into individual assistant content for HA
|
||||
# to ensure that tools are called as soon as possible.
|
||||
yield {"role": "assistant"}
|
||||
current_tool_call = event.item
|
||||
elif isinstance(event, ResponseOutputItemDoneEvent):
|
||||
item = event.item.model_dump()
|
||||
item.pop("status", None)
|
||||
if isinstance(event.item, ResponseReasoningItem):
|
||||
messages.append(cast(ResponseReasoningItemParam, item))
|
||||
elif isinstance(event.item, ResponseOutputMessage):
|
||||
messages.append(cast(ResponseOutputMessageParam, item))
|
||||
elif isinstance(event.item, ResponseFunctionToolCall):
|
||||
messages.append(cast(ResponseFunctionToolCallParam, item))
|
||||
elif isinstance(event, ResponseTextDeltaEvent):
|
||||
yield {"content": event.delta}
|
||||
elif isinstance(event, ResponseFunctionCallArgumentsDeltaEvent):
|
||||
current_tool_call.arguments += event.delta
|
||||
elif isinstance(event, ResponseFunctionCallArgumentsDoneEvent):
|
||||
current_tool_call.status = "completed"
|
||||
yield {
|
||||
"tool_calls": [
|
||||
llm.ToolInput(
|
||||
id=current_tool_call.call_id,
|
||||
tool_name=current_tool_call.name,
|
||||
tool_args=json.loads(current_tool_call.arguments),
|
||||
)
|
||||
]
|
||||
}
|
||||
elif isinstance(event, ResponseCompletedEvent):
|
||||
if event.response.usage is not None:
|
||||
chat_log.async_trace(
|
||||
{
|
||||
"stats": {
|
||||
"input_tokens": event.response.usage.input_tokens,
|
||||
"output_tokens": event.response.usage.output_tokens,
|
||||
}
|
||||
}
|
||||
)
|
||||
elif isinstance(event, ResponseIncompleteEvent):
|
||||
if event.response.usage is not None:
|
||||
chat_log.async_trace(
|
||||
{
|
||||
"stats": {
|
||||
"input_tokens": event.response.usage.input_tokens,
|
||||
"output_tokens": event.response.usage.output_tokens,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (
|
||||
event.response.incomplete_details
|
||||
and event.response.incomplete_details.reason
|
||||
):
|
||||
reason: str = event.response.incomplete_details.reason
|
||||
else:
|
||||
reason = "unknown reason"
|
||||
|
||||
if reason == "max_output_tokens":
|
||||
reason = "max output tokens reached"
|
||||
elif reason == "content_filter":
|
||||
reason = "content filter triggered"
|
||||
|
||||
raise HomeAssistantError(f"OpenAI response incomplete: {reason}")
|
||||
elif isinstance(event, ResponseFailedEvent):
|
||||
if event.response.usage is not None:
|
||||
chat_log.async_trace(
|
||||
{
|
||||
"stats": {
|
||||
"input_tokens": event.response.usage.input_tokens,
|
||||
"output_tokens": event.response.usage.output_tokens,
|
||||
}
|
||||
}
|
||||
)
|
||||
reason = "unknown reason"
|
||||
if event.response.error is not None:
|
||||
reason = event.response.error.message
|
||||
raise HomeAssistantError(f"OpenAI response failed: {reason}")
|
||||
elif isinstance(event, ResponseErrorEvent):
|
||||
raise HomeAssistantError(f"OpenAI response error: {event.message}")
|
||||
|
||||
|
||||
class OpenAILLMBaseEntity(Entity):
|
||||
"""OpenAI conversation agent."""
|
||||
|
||||
def __init__(self, entry: OpenAIConfigEntry, subentry: ConfigSubentry) -> None:
|
||||
"""Initialize the agent."""
|
||||
self.entry = entry
|
||||
self.subentry = subentry
|
||||
self._attr_unique_id = subentry.subentry_id
|
||||
self._attr_device_info = dr.DeviceInfo(
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
name=entry.title,
|
||||
manufacturer="OpenAI",
|
||||
model="ChatGPT",
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
async def _async_handle_chat_log(
|
||||
self,
|
||||
chat_log: conversation.ChatLog,
|
||||
) -> None:
|
||||
"""Generate an answer for the chat log."""
|
||||
options = self.subentry.data
|
||||
|
||||
tools: list[ToolParam] | None = None
|
||||
if chat_log.llm_api:
|
||||
tools = [
|
||||
_format_tool(tool, chat_log.llm_api.custom_serializer)
|
||||
for tool in chat_log.llm_api.tools
|
||||
]
|
||||
|
||||
if options.get(CONF_WEB_SEARCH):
|
||||
web_search = WebSearchToolParam(
|
||||
type="web_search_preview",
|
||||
search_context_size=options.get(
|
||||
CONF_WEB_SEARCH_CONTEXT_SIZE, RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE
|
||||
),
|
||||
)
|
||||
if options.get(CONF_WEB_SEARCH_USER_LOCATION):
|
||||
web_search["user_location"] = UserLocation(
|
||||
type="approximate",
|
||||
city=options.get(CONF_WEB_SEARCH_CITY, ""),
|
||||
region=options.get(CONF_WEB_SEARCH_REGION, ""),
|
||||
country=options.get(CONF_WEB_SEARCH_COUNTRY, ""),
|
||||
timezone=options.get(CONF_WEB_SEARCH_TIMEZONE, ""),
|
||||
)
|
||||
if tools is None:
|
||||
tools = []
|
||||
tools.append(web_search)
|
||||
|
||||
model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
|
||||
messages = [
|
||||
m
|
||||
for content in chat_log.content
|
||||
for m in _convert_content_to_param(content)
|
||||
]
|
||||
|
||||
client = self.entry.runtime_data
|
||||
|
||||
# To prevent infinite loops, we limit the number of iterations
|
||||
for _iteration in range(MAX_TOOL_ITERATIONS):
|
||||
model_args = {
|
||||
"model": model,
|
||||
"input": messages,
|
||||
"max_output_tokens": options.get(
|
||||
CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS
|
||||
),
|
||||
"top_p": options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
|
||||
"temperature": options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE),
|
||||
"user": chat_log.conversation_id,
|
||||
"stream": True,
|
||||
}
|
||||
if tools:
|
||||
model_args["tools"] = tools
|
||||
|
||||
if model.startswith("o"):
|
||||
model_args["reasoning"] = {
|
||||
"effort": options.get(
|
||||
CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT
|
||||
)
|
||||
}
|
||||
else:
|
||||
model_args["store"] = False
|
||||
|
||||
try:
|
||||
result = await client.responses.create(**model_args)
|
||||
except openai.RateLimitError as err:
|
||||
LOGGER.error("Rate limited by OpenAI: %s", err)
|
||||
raise HomeAssistantError("Rate limited or insufficient funds") from err
|
||||
except openai.OpenAIError as err:
|
||||
LOGGER.error("Error talking to OpenAI: %s", err)
|
||||
raise HomeAssistantError("Error talking to OpenAI") from err
|
||||
|
||||
async for content in chat_log.async_add_delta_content_stream(
|
||||
self.entity_id, _transform_stream(chat_log, result, messages)
|
||||
):
|
||||
if not isinstance(content, conversation.AssistantContent):
|
||||
messages.extend(_convert_content_to_param(content))
|
||||
|
||||
if not chat_log.unresponded_tool_results:
|
||||
break
|
||||
@@ -13,102 +13,45 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
}
|
||||
},
|
||||
"config_subentries": {
|
||||
"conversation": {
|
||||
"initiate_flow": {
|
||||
"user": "Add conversation agent",
|
||||
"reconfigure": "Reconfigure conversation agent"
|
||||
},
|
||||
"entry_type": "Conversation agent",
|
||||
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"prompt": "Instructions",
|
||||
"llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]",
|
||||
"recommended": "Recommended model settings"
|
||||
},
|
||||
"data_description": {
|
||||
"prompt": "Instruct how the LLM should respond. This can be a template."
|
||||
}
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"prompt": "Instructions",
|
||||
"llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]",
|
||||
"recommended": "Recommended model settings"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced settings",
|
||||
"data": {
|
||||
"chat_model": "[%key:common::generic::model%]",
|
||||
"max_tokens": "Maximum tokens to return in response",
|
||||
"temperature": "Temperature",
|
||||
"top_p": "Top P"
|
||||
}
|
||||
},
|
||||
"model": {
|
||||
"title": "Model-specific options",
|
||||
"data": {
|
||||
"reasoning_effort": "Reasoning effort",
|
||||
"web_search": "Enable web search",
|
||||
"search_context_size": "Search context size",
|
||||
"user_location": "Include home location"
|
||||
},
|
||||
"data_description": {
|
||||
"reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt",
|
||||
"web_search": "Allow the model to search the web for the latest information before generating a response",
|
||||
"search_context_size": "High level guidance for the amount of context window space to use for the search",
|
||||
"user_location": "Refine search results based on geography"
|
||||
}
|
||||
"data_description": {
|
||||
"prompt": "Instruct how the LLM should respond. This can be a template."
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
"advanced": {
|
||||
"title": "Advanced settings",
|
||||
"data": {
|
||||
"chat_model": "[%key:common::generic::model%]",
|
||||
"max_tokens": "Maximum tokens to return in response",
|
||||
"temperature": "Temperature",
|
||||
"top_p": "Top P"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"model_not_supported": "This model is not supported, please select a different model"
|
||||
"model": {
|
||||
"title": "Model-specific options",
|
||||
"data": {
|
||||
"reasoning_effort": "Reasoning effort",
|
||||
"web_search": "Enable web search",
|
||||
"search_context_size": "Search context size",
|
||||
"user_location": "Include home location"
|
||||
},
|
||||
"data_description": {
|
||||
"reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt",
|
||||
"web_search": "Allow the model to search the web for the latest information before generating a response",
|
||||
"search_context_size": "High level guidance for the amount of context window space to use for the search",
|
||||
"user_location": "Refine search results based on geography"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ai_task": {
|
||||
"initiate_flow": {
|
||||
"user": "Add AI task service",
|
||||
"reconfigure": "Reconfigure AI task service"
|
||||
},
|
||||
"entry_type": "AI task service",
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"recommended": "[%key:component::openai_conversation::config_subentries::conversation::step::init::data::recommended%]"
|
||||
}
|
||||
},
|
||||
"advanced": {
|
||||
"title": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::title%]",
|
||||
"data": {
|
||||
"chat_model": "[%key:common::generic::model%]",
|
||||
"max_tokens": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data::max_tokens%]",
|
||||
"temperature": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data::temperature%]",
|
||||
"top_p": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data::top_p%]"
|
||||
}
|
||||
},
|
||||
"model": {
|
||||
"title": "[%key:component::openai_conversation::config_subentries::conversation::step::model::title%]",
|
||||
"data": {
|
||||
"reasoning_effort": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::reasoning_effort%]",
|
||||
"web_search": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::web_search%]",
|
||||
"search_context_size": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::search_context_size%]",
|
||||
"user_location": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::user_location%]"
|
||||
},
|
||||
"data_description": {
|
||||
"reasoning_effort": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::reasoning_effort%]",
|
||||
"web_search": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::web_search%]",
|
||||
"search_context_size": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::search_context_size%]",
|
||||
"user_location": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::user_location%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"model_not_supported": "[%key:component::openai_conversation::config_subentries::conversation::error::model_not_supported%]"
|
||||
}
|
||||
"error": {
|
||||
"model_not_supported": "This model is not supported, please select a different model"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
|
||||
@@ -9,8 +9,6 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
|
||||
|
||||
from .const import DOMAIN, RUSSOUND_RIO_EXCEPTIONS
|
||||
|
||||
@@ -54,39 +52,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: RussoundConfigEntry) ->
|
||||
) from err
|
||||
entry.runtime_data = client
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
|
||||
for controller_id, controller in client.controllers.items():
|
||||
_device_identifier = (
|
||||
controller.mac_address
|
||||
or f"{client.controllers[1].mac_address}-{controller_id}"
|
||||
)
|
||||
connections = None
|
||||
via_device = None
|
||||
configuration_url = None
|
||||
if controller_id != 1:
|
||||
assert client.controllers[1].mac_address
|
||||
via_device = (
|
||||
DOMAIN,
|
||||
client.controllers[1].mac_address,
|
||||
)
|
||||
else:
|
||||
assert controller.mac_address
|
||||
connections = {(CONNECTION_NETWORK_MAC, controller.mac_address)}
|
||||
if isinstance(client.connection_handler, RussoundTcpConnectionHandler):
|
||||
configuration_url = f"http://{client.connection_handler.host}"
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, _device_identifier)},
|
||||
manufacturer="Russound",
|
||||
name=controller.controller_type,
|
||||
model=controller.controller_type,
|
||||
sw_version=controller.firmware_version,
|
||||
connections=connections,
|
||||
via_device=via_device,
|
||||
configuration_url=configuration_url,
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
@@ -4,11 +4,11 @@ from collections.abc import Awaitable, Callable, Coroutine
|
||||
from functools import wraps
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from aiorussound import Controller, RussoundClient
|
||||
from aiorussound import Controller, RussoundClient, RussoundTcpConnectionHandler
|
||||
from aiorussound.models import CallbackType
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .const import DOMAIN, RUSSOUND_RIO_EXCEPTIONS
|
||||
@@ -46,7 +46,6 @@ class RussoundBaseEntity(Entity):
|
||||
def __init__(
|
||||
self,
|
||||
controller: Controller,
|
||||
zone_id: int | None = None,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
self._client = controller.client
|
||||
@@ -58,21 +57,29 @@ class RussoundBaseEntity(Entity):
|
||||
self._controller.mac_address
|
||||
or f"{self._primary_mac_address}-{self._controller.controller_id}"
|
||||
)
|
||||
if not zone_id:
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._device_identifier)},
|
||||
)
|
||||
return
|
||||
zone = controller.zones[zone_id]
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{self._device_identifier}-{zone_id}")},
|
||||
name=zone.name,
|
||||
# Use MAC address of Russound device as identifier
|
||||
identifiers={(DOMAIN, self._device_identifier)},
|
||||
manufacturer="Russound",
|
||||
name=controller.controller_type,
|
||||
model=controller.controller_type,
|
||||
sw_version=controller.firmware_version,
|
||||
suggested_area=zone.name,
|
||||
via_device=(DOMAIN, self._device_identifier),
|
||||
)
|
||||
if isinstance(self._client.connection_handler, RussoundTcpConnectionHandler):
|
||||
self._attr_device_info["configuration_url"] = (
|
||||
f"http://{self._client.connection_handler.host}"
|
||||
)
|
||||
if controller.controller_id != 1:
|
||||
assert self._client.controllers[1].mac_address
|
||||
self._attr_device_info["via_device"] = (
|
||||
DOMAIN,
|
||||
self._client.controllers[1].mac_address,
|
||||
)
|
||||
else:
|
||||
assert controller.mac_address
|
||||
self._attr_device_info["connections"] = {
|
||||
(CONNECTION_NETWORK_MAC, controller.mac_address)
|
||||
}
|
||||
|
||||
async def _state_update_callback(
|
||||
self, _client: RussoundClient, _callback_type: CallbackType
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiorussound"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aiorussound==4.6.1"],
|
||||
"requirements": ["aiorussound==4.6.0"],
|
||||
"zeroconf": ["_rio._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -60,16 +60,16 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity):
|
||||
| MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
| MediaPlayerEntityFeature.SEEK
|
||||
)
|
||||
_attr_name = None
|
||||
|
||||
def __init__(
|
||||
self, controller: Controller, zone_id: int, sources: dict[int, Source]
|
||||
) -> None:
|
||||
"""Initialize the zone device."""
|
||||
super().__init__(controller, zone_id)
|
||||
super().__init__(controller)
|
||||
self._zone_id = zone_id
|
||||
_zone = self._zone
|
||||
self._sources = sources
|
||||
self._attr_name = _zone.name
|
||||
self._attr_unique_id = f"{self._primary_mac_address}-{_zone.device_str}"
|
||||
|
||||
@property
|
||||
|
||||
@@ -653,6 +653,7 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity):
|
||||
)
|
||||
elif entry is not None:
|
||||
self._attr_unique_id = entry.unique_id
|
||||
self._attr_name = cast(str, entry.original_name)
|
||||
|
||||
@callback
|
||||
def _update_callback(self) -> None:
|
||||
|
||||
@@ -321,8 +321,8 @@ class TelegramNotificationService:
|
||||
for key in row_keyboard.split(","):
|
||||
if ":/" in key:
|
||||
# check if command or URL
|
||||
if "https://" in key:
|
||||
label = key.split(":")[0]
|
||||
if key.startswith("https://"):
|
||||
label = key.split(",")[0]
|
||||
url = key[len(label) + 1 :]
|
||||
buttons.append(InlineKeyboardButton(label, url=url))
|
||||
else:
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
"""Support for Traccar Client."""
|
||||
|
||||
from http import HTTPStatus
|
||||
from json import JSONDecodeError
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
import voluptuous as vol
|
||||
from voluptuous.humanize import humanize_error
|
||||
|
||||
from homeassistant.components import webhook
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -23,6 +20,7 @@ from .const import (
|
||||
ATTR_LATITUDE,
|
||||
ATTR_LONGITUDE,
|
||||
ATTR_SPEED,
|
||||
ATTR_TIMESTAMP,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
@@ -31,7 +29,6 @@ PLATFORMS = [Platform.DEVICE_TRACKER]
|
||||
|
||||
TRACKER_UPDATE = f"{DOMAIN}_tracker_update"
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_ACCURACY = 200
|
||||
DEFAULT_BATTERY = -1
|
||||
@@ -52,50 +49,21 @@ WEBHOOK_SCHEMA = vol.Schema(
|
||||
vol.Optional(ATTR_BATTERY, default=DEFAULT_BATTERY): vol.Coerce(float),
|
||||
vol.Optional(ATTR_BEARING): vol.Coerce(float),
|
||||
vol.Optional(ATTR_SPEED): vol.Coerce(float),
|
||||
vol.Optional(ATTR_TIMESTAMP): vol.Coerce(int),
|
||||
},
|
||||
extra=vol.REMOVE_EXTRA,
|
||||
)
|
||||
|
||||
|
||||
def _parse_json_body(json_body: dict) -> dict:
|
||||
"""Parse JSON body from request."""
|
||||
location = json_body.get("location", {})
|
||||
coords = location.get("coords", {})
|
||||
battery_level = location.get("battery", {}).get("level")
|
||||
return {
|
||||
"id": json_body.get("device_id"),
|
||||
"lat": coords.get("latitude"),
|
||||
"lon": coords.get("longitude"),
|
||||
"accuracy": coords.get("accuracy"),
|
||||
"altitude": coords.get("altitude"),
|
||||
"batt": battery_level * 100 if battery_level is not None else DEFAULT_BATTERY,
|
||||
"bearing": coords.get("heading"),
|
||||
"speed": coords.get("speed"),
|
||||
}
|
||||
|
||||
|
||||
async def handle_webhook(
|
||||
hass: HomeAssistant,
|
||||
webhook_id: str,
|
||||
request: web.Request,
|
||||
hass: HomeAssistant, webhook_id: str, request: web.Request
|
||||
) -> web.Response:
|
||||
"""Handle incoming webhook with Traccar Client request."""
|
||||
if not (requestdata := dict(request.query)):
|
||||
try:
|
||||
requestdata = _parse_json_body(await request.json())
|
||||
except JSONDecodeError as error:
|
||||
LOGGER.error("Error parsing JSON body: %s", error)
|
||||
return web.Response(
|
||||
text="Invalid JSON",
|
||||
status=HTTPStatus.UNPROCESSABLE_ENTITY,
|
||||
)
|
||||
try:
|
||||
data = WEBHOOK_SCHEMA(requestdata)
|
||||
data = WEBHOOK_SCHEMA(dict(request.query))
|
||||
except vol.MultipleInvalid as error:
|
||||
LOGGER.warning(humanize_error(requestdata, error))
|
||||
return web.Response(
|
||||
text=error.error_message,
|
||||
status=HTTPStatus.UNPROCESSABLE_ENTITY,
|
||||
text=error.error_message, status=HTTPStatus.UNPROCESSABLE_ENTITY
|
||||
)
|
||||
|
||||
attrs = {
|
||||
|
||||
@@ -17,6 +17,7 @@ ATTR_LONGITUDE = "lon"
|
||||
ATTR_MOTION = "motion"
|
||||
ATTR_SPEED = "speed"
|
||||
ATTR_STATUS = "status"
|
||||
ATTR_TIMESTAMP = "timestamp"
|
||||
ATTR_TRACKER = "tracker"
|
||||
ATTR_TRACCAR_ID = "traccar_id"
|
||||
|
||||
|
||||
@@ -89,11 +89,11 @@ class TextToSpeechEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH
|
||||
"""Return a mapping with the default options."""
|
||||
return self._attr_default_options
|
||||
|
||||
def async_supports_streaming_input(self) -> bool:
|
||||
@classmethod
|
||||
def async_supports_streaming_input(cls) -> bool:
|
||||
"""Return if the TTS engine supports streaming input."""
|
||||
return (
|
||||
self.__class__.async_stream_tts_audio
|
||||
is not TextToSpeechEntity.async_stream_tts_audio
|
||||
cls.async_stream_tts_audio is not TextToSpeechEntity.async_stream_tts_audio
|
||||
)
|
||||
|
||||
@callback
|
||||
|
||||
@@ -32,19 +32,19 @@ from zwave_js_server.exceptions import (
|
||||
NotFoundError,
|
||||
SetValueFailed,
|
||||
)
|
||||
from zwave_js_server.firmware import driver_firmware_update_otw, update_firmware
|
||||
from zwave_js_server.firmware import controller_firmware_update_otw, update_firmware
|
||||
from zwave_js_server.model.controller import (
|
||||
ControllerStatistics,
|
||||
InclusionGrant,
|
||||
ProvisioningEntry,
|
||||
QRProvisioningInformation,
|
||||
)
|
||||
from zwave_js_server.model.driver import Driver
|
||||
from zwave_js_server.model.driver.firmware import (
|
||||
DriverFirmwareUpdateData,
|
||||
DriverFirmwareUpdateProgress,
|
||||
DriverFirmwareUpdateResult,
|
||||
from zwave_js_server.model.controller.firmware import (
|
||||
ControllerFirmwareUpdateData,
|
||||
ControllerFirmwareUpdateProgress,
|
||||
ControllerFirmwareUpdateResult,
|
||||
)
|
||||
from zwave_js_server.model.driver import Driver
|
||||
from zwave_js_server.model.endpoint import Endpoint
|
||||
from zwave_js_server.model.log_config import LogConfig
|
||||
from zwave_js_server.model.log_message import LogMessage
|
||||
@@ -2340,8 +2340,8 @@ def _get_node_firmware_update_progress_dict(
|
||||
}
|
||||
|
||||
|
||||
def _get_driver_firmware_update_progress_dict(
|
||||
progress: DriverFirmwareUpdateProgress,
|
||||
def _get_controller_firmware_update_progress_dict(
|
||||
progress: ControllerFirmwareUpdateProgress,
|
||||
) -> dict[str, int | float]:
|
||||
"""Get a dictionary of a controller's firmware update progress."""
|
||||
return {
|
||||
@@ -2370,8 +2370,7 @@ async def websocket_subscribe_firmware_update_status(
|
||||
) -> None:
|
||||
"""Subscribe to the status of a firmware update."""
|
||||
assert node.client.driver
|
||||
driver = node.client.driver
|
||||
controller = driver.controller
|
||||
controller = node.client.driver.controller
|
||||
|
||||
@callback
|
||||
def async_cleanup() -> None:
|
||||
@@ -2409,21 +2408,21 @@ async def websocket_subscribe_firmware_update_status(
|
||||
)
|
||||
|
||||
@callback
|
||||
def forward_driver_progress(event: dict) -> None:
|
||||
progress: DriverFirmwareUpdateProgress = event["firmware_update_progress"]
|
||||
def forward_controller_progress(event: dict) -> None:
|
||||
progress: ControllerFirmwareUpdateProgress = event["firmware_update_progress"]
|
||||
connection.send_message(
|
||||
websocket_api.event_message(
|
||||
msg[ID],
|
||||
{
|
||||
"event": event["event"],
|
||||
**_get_driver_firmware_update_progress_dict(progress),
|
||||
**_get_controller_firmware_update_progress_dict(progress),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def forward_driver_finished(event: dict) -> None:
|
||||
finished: DriverFirmwareUpdateResult = event["firmware_update_finished"]
|
||||
def forward_controller_finished(event: dict) -> None:
|
||||
finished: ControllerFirmwareUpdateResult = event["firmware_update_finished"]
|
||||
connection.send_message(
|
||||
websocket_api.event_message(
|
||||
msg[ID],
|
||||
@@ -2437,8 +2436,8 @@ async def websocket_subscribe_firmware_update_status(
|
||||
|
||||
if controller.own_node == node:
|
||||
msg[DATA_UNSUBSCRIBE] = unsubs = [
|
||||
driver.on("firmware update progress", forward_driver_progress),
|
||||
driver.on("firmware update finished", forward_driver_finished),
|
||||
controller.on("firmware update progress", forward_controller_progress),
|
||||
controller.on("firmware update finished", forward_controller_finished),
|
||||
]
|
||||
else:
|
||||
msg[DATA_UNSUBSCRIBE] = unsubs = [
|
||||
@@ -2448,13 +2447,17 @@ async def websocket_subscribe_firmware_update_status(
|
||||
connection.subscriptions[msg["id"]] = async_cleanup
|
||||
|
||||
connection.send_result(msg[ID])
|
||||
if node.is_controller_node and (driver_progress := driver.firmware_update_progress):
|
||||
if node.is_controller_node and (
|
||||
controller_progress := controller.firmware_update_progress
|
||||
):
|
||||
connection.send_message(
|
||||
websocket_api.event_message(
|
||||
msg[ID],
|
||||
{
|
||||
"event": "firmware update progress",
|
||||
**_get_driver_firmware_update_progress_dict(driver_progress),
|
||||
**_get_controller_firmware_update_progress_dict(
|
||||
controller_progress
|
||||
),
|
||||
},
|
||||
)
|
||||
)
|
||||
@@ -2556,9 +2559,9 @@ class FirmwareUploadView(HomeAssistantView):
|
||||
|
||||
try:
|
||||
if node.client.driver.controller.own_node == node:
|
||||
await driver_firmware_update_otw(
|
||||
await controller_firmware_update_otw(
|
||||
node.client.ws_server_url,
|
||||
DriverFirmwareUpdateData(
|
||||
ControllerFirmwareUpdateData(
|
||||
uploaded_file.filename,
|
||||
await hass.async_add_executor_job(uploaded_file.file.read),
|
||||
),
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["zwave_js_server"],
|
||||
"requirements": ["pyserial==3.5", "zwave-js-server-python==0.64.0"],
|
||||
"requirements": ["pyserial==3.5", "zwave-js-server-python==0.63.0"],
|
||||
"usb": [
|
||||
{
|
||||
"vid": "0658",
|
||||
|
||||
@@ -3420,11 +3420,6 @@ class ConfigSubentryFlow(
|
||||
"""Return config entry id."""
|
||||
return self.handler[0]
|
||||
|
||||
@property
|
||||
def _subentry_type(self) -> str:
|
||||
"""Return type of subentry we are editing/creating."""
|
||||
return self.handler[1]
|
||||
|
||||
@callback
|
||||
def _get_entry(self) -> ConfigEntry:
|
||||
"""Return the config entry linked to the current context."""
|
||||
|
||||
@@ -40,7 +40,6 @@ PLATFORM_FORMAT: Final = "{platform}.{domain}"
|
||||
class Platform(StrEnum):
|
||||
"""Available entity platforms."""
|
||||
|
||||
AI_TASK = "ai_task"
|
||||
AIR_QUALITY = "air_quality"
|
||||
ALARM_CONTROL_PANEL = "alarm_control_panel"
|
||||
ASSIST_SATELLITE = "assist_satellite"
|
||||
|
||||
Generated
-1
@@ -313,7 +313,6 @@ FLOWS = {
|
||||
"izone",
|
||||
"jellyfin",
|
||||
"jewish_calendar",
|
||||
"juicenet",
|
||||
"justnimbus",
|
||||
"jvc_projector",
|
||||
"kaleidescape",
|
||||
|
||||
@@ -3175,12 +3175,6 @@
|
||||
"config_flow": false,
|
||||
"iot_class": "cloud_push"
|
||||
},
|
||||
"juicenet": {
|
||||
"name": "JuiceNet",
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
},
|
||||
"justnimbus": {
|
||||
"name": "JustNimbus",
|
||||
"integration_type": "hub",
|
||||
|
||||
Generated
+7
-10
@@ -182,7 +182,7 @@ aioairzone-cloud==0.6.12
|
||||
aioairzone==1.0.0
|
||||
|
||||
# homeassistant.components.alexa_devices
|
||||
aioamazondevices==3.1.14
|
||||
aioamazondevices==3.1.12
|
||||
|
||||
# homeassistant.components.ambient_network
|
||||
# homeassistant.components.ambient_station
|
||||
@@ -265,7 +265,7 @@ aioharmony==0.5.2
|
||||
aiohasupervisor==0.3.1
|
||||
|
||||
# homeassistant.components.home_connect
|
||||
aiohomeconnect==0.18.1
|
||||
aiohomeconnect==0.18.0
|
||||
|
||||
# homeassistant.components.homekit_controller
|
||||
aiohomekit==3.2.15
|
||||
@@ -369,7 +369,7 @@ aioridwell==2024.01.0
|
||||
aioruckus==0.42
|
||||
|
||||
# homeassistant.components.russound_rio
|
||||
aiorussound==4.6.1
|
||||
aiorussound==4.6.0
|
||||
|
||||
# homeassistant.components.ruuvi_gateway
|
||||
aioruuvigateway==0.1.0
|
||||
@@ -765,7 +765,7 @@ decora-wifi==1.4
|
||||
# decora==0.6
|
||||
|
||||
# homeassistant.components.ecovacs
|
||||
deebot-client==13.4.0
|
||||
deebot-client==13.3.0
|
||||
|
||||
# homeassistant.components.ihc
|
||||
# homeassistant.components.namecheapdns
|
||||
@@ -1140,7 +1140,7 @@ hdate[astral]==1.1.2
|
||||
heatmiserV3==2.0.3
|
||||
|
||||
# homeassistant.components.here_travel_time
|
||||
here-routing==1.2.0
|
||||
here-routing==1.0.1
|
||||
|
||||
# homeassistant.components.here_travel_time
|
||||
here-transit==1.2.1
|
||||
@@ -1799,7 +1799,7 @@ pyEmby==1.10
|
||||
pyHik==0.3.2
|
||||
|
||||
# homeassistant.components.homee
|
||||
pyHomee==1.2.10
|
||||
pyHomee==1.2.9
|
||||
|
||||
# homeassistant.components.rfxtrx
|
||||
pyRFXtrx==0.31.1
|
||||
@@ -2446,9 +2446,6 @@ python-izone==1.2.9
|
||||
# homeassistant.components.joaoapps_join
|
||||
python-join-api==0.0.9
|
||||
|
||||
# homeassistant.components.juicenet
|
||||
python-juicenet==1.1.0
|
||||
|
||||
# homeassistant.components.tplink
|
||||
python-kasa[speedups]==0.10.2
|
||||
|
||||
@@ -3193,7 +3190,7 @@ ziggo-mediabox-xl==1.1.0
|
||||
zm-py==0.5.4
|
||||
|
||||
# homeassistant.components.zwave_js
|
||||
zwave-js-server-python==0.64.0
|
||||
zwave-js-server-python==0.63.0
|
||||
|
||||
# homeassistant.components.zwave_me
|
||||
zwave-me-ws==0.4.3
|
||||
|
||||
Generated
+7
-10
@@ -170,7 +170,7 @@ aioairzone-cloud==0.6.12
|
||||
aioairzone==1.0.0
|
||||
|
||||
# homeassistant.components.alexa_devices
|
||||
aioamazondevices==3.1.14
|
||||
aioamazondevices==3.1.12
|
||||
|
||||
# homeassistant.components.ambient_network
|
||||
# homeassistant.components.ambient_station
|
||||
@@ -250,7 +250,7 @@ aioharmony==0.5.2
|
||||
aiohasupervisor==0.3.1
|
||||
|
||||
# homeassistant.components.home_connect
|
||||
aiohomeconnect==0.18.1
|
||||
aiohomeconnect==0.18.0
|
||||
|
||||
# homeassistant.components.homekit_controller
|
||||
aiohomekit==3.2.15
|
||||
@@ -351,7 +351,7 @@ aioridwell==2024.01.0
|
||||
aioruckus==0.42
|
||||
|
||||
# homeassistant.components.russound_rio
|
||||
aiorussound==4.6.1
|
||||
aiorussound==4.6.0
|
||||
|
||||
# homeassistant.components.ruuvi_gateway
|
||||
aioruuvigateway==0.1.0
|
||||
@@ -665,7 +665,7 @@ debugpy==1.8.14
|
||||
# decora==0.6
|
||||
|
||||
# homeassistant.components.ecovacs
|
||||
deebot-client==13.4.0
|
||||
deebot-client==13.3.0
|
||||
|
||||
# homeassistant.components.ihc
|
||||
# homeassistant.components.namecheapdns
|
||||
@@ -992,7 +992,7 @@ hassil==2.2.3
|
||||
hdate[astral]==1.1.2
|
||||
|
||||
# homeassistant.components.here_travel_time
|
||||
here-routing==1.2.0
|
||||
here-routing==1.0.1
|
||||
|
||||
# homeassistant.components.here_travel_time
|
||||
here-transit==1.2.1
|
||||
@@ -1510,7 +1510,7 @@ pyDuotecno==2024.10.1
|
||||
pyElectra==1.2.4
|
||||
|
||||
# homeassistant.components.homee
|
||||
pyHomee==1.2.10
|
||||
pyHomee==1.2.9
|
||||
|
||||
# homeassistant.components.rfxtrx
|
||||
pyRFXtrx==0.31.1
|
||||
@@ -2016,9 +2016,6 @@ python-homewizard-energy==9.1.1
|
||||
# homeassistant.components.izone
|
||||
python-izone==1.2.9
|
||||
|
||||
# homeassistant.components.juicenet
|
||||
python-juicenet==1.1.0
|
||||
|
||||
# homeassistant.components.tplink
|
||||
python-kasa[speedups]==0.10.2
|
||||
|
||||
@@ -2625,7 +2622,7 @@ zeversolar==0.3.2
|
||||
zha==0.0.60
|
||||
|
||||
# homeassistant.components.zwave_js
|
||||
zwave-js-server-python==0.64.0
|
||||
zwave-js-server-python==0.63.0
|
||||
|
||||
# homeassistant.components.zwave_me
|
||||
zwave-me-ws==0.4.3
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Tests for the AI Task integration."""
|
||||
@@ -1,127 +0,0 @@
|
||||
"""Test helpers for AI Task integration."""
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.ai_task import (
|
||||
DOMAIN,
|
||||
AITaskEntity,
|
||||
AITaskEntityFeature,
|
||||
GenTextTask,
|
||||
GenTextTaskResult,
|
||||
)
|
||||
from homeassistant.components.conversation import AssistantContent, ChatLog
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigFlow
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import (
|
||||
MockConfigEntry,
|
||||
MockModule,
|
||||
MockPlatform,
|
||||
mock_config_flow,
|
||||
mock_integration,
|
||||
mock_platform,
|
||||
)
|
||||
|
||||
TEST_DOMAIN = "test"
|
||||
TEST_ENTITY_ID = "ai_task.test_task_entity"
|
||||
|
||||
|
||||
class MockAITaskEntity(AITaskEntity):
|
||||
"""Mock AI Task entity for testing."""
|
||||
|
||||
_attr_name = "Test Task Entity"
|
||||
_attr_supported_features = AITaskEntityFeature.GENERATE_TEXT
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the mock entity."""
|
||||
super().__init__()
|
||||
self.mock_generate_text_tasks = []
|
||||
|
||||
async def _async_generate_text(
|
||||
self, task: GenTextTask, chat_log: ChatLog
|
||||
) -> GenTextTaskResult:
|
||||
"""Mock handling of generate text task."""
|
||||
self.mock_generate_text_tasks.append(task)
|
||||
chat_log.async_add_assistant_content_without_tools(
|
||||
AssistantContent(self.entity_id, "Mock result")
|
||||
)
|
||||
return GenTextTaskResult(
|
||||
conversation_id=chat_log.conversation_id,
|
||||
text="Mock result",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
|
||||
"""Mock a configuration entry for AI Task."""
|
||||
entry = MockConfigEntry(domain=TEST_DOMAIN, entry_id="mock-test-entry")
|
||||
entry.add_to_hass(hass)
|
||||
return entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ai_task_entity(
|
||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry
|
||||
) -> MockAITaskEntity:
|
||||
"""Mock AI Task entity."""
|
||||
return MockAITaskEntity()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def init_components(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_ai_task_entity: MockAITaskEntity,
|
||||
):
|
||||
"""Initialize the AI Task integration with a mock entity."""
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
|
||||
async def async_setup_entry_init(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry
|
||||
) -> bool:
|
||||
"""Set up test config entry."""
|
||||
await hass.config_entries.async_forward_entry_setups(
|
||||
config_entry, [Platform.AI_TASK]
|
||||
)
|
||||
return True
|
||||
|
||||
async def async_unload_entry_init(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry
|
||||
) -> bool:
|
||||
"""Unload test config entry."""
|
||||
await hass.config_entries.async_forward_entry_unload(
|
||||
config_entry, Platform.AI_TASK
|
||||
)
|
||||
return True
|
||||
|
||||
mock_integration(
|
||||
hass,
|
||||
MockModule(
|
||||
TEST_DOMAIN,
|
||||
async_setup_entry=async_setup_entry_init,
|
||||
async_unload_entry=async_unload_entry_init,
|
||||
),
|
||||
)
|
||||
|
||||
async def async_setup_entry_platform(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up test tts platform via config entry."""
|
||||
async_add_entities([mock_ai_task_entity])
|
||||
|
||||
mock_platform(
|
||||
hass,
|
||||
f"{TEST_DOMAIN}.{DOMAIN}",
|
||||
MockPlatform(async_setup_entry=async_setup_entry_platform),
|
||||
)
|
||||
|
||||
mock_platform(hass, f"{TEST_DOMAIN}.config_flow")
|
||||
|
||||
with mock_config_flow(TEST_DOMAIN, ConfigFlow):
|
||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
@@ -1,22 +0,0 @@
|
||||
# serializer version: 1
|
||||
# name: test_run_text_task_updates_chat_log
|
||||
list([
|
||||
dict({
|
||||
'content': '''
|
||||
You are a Home Assistant expert and help users with their tasks.
|
||||
Current time is 15:59:00. Today's date is 2025-06-14.
|
||||
''',
|
||||
'role': 'system',
|
||||
}),
|
||||
dict({
|
||||
'content': 'Test prompt',
|
||||
'role': 'user',
|
||||
}),
|
||||
dict({
|
||||
'agent_id': 'ai_task.test_task_entity',
|
||||
'content': 'Mock result',
|
||||
'role': 'assistant',
|
||||
'tool_calls': None,
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
@@ -1,39 +0,0 @@
|
||||
"""Tests for the AI Task entity model."""
|
||||
|
||||
from freezegun import freeze_time
|
||||
|
||||
from homeassistant.components.ai_task import async_generate_text
|
||||
from homeassistant.const import STATE_UNKNOWN
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .conftest import TEST_ENTITY_ID, MockAITaskEntity
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@freeze_time("2025-06-08 16:28:13")
|
||||
async def test_state_generate_text(
|
||||
hass: HomeAssistant,
|
||||
init_components: None,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_ai_task_entity: MockAITaskEntity,
|
||||
) -> None:
|
||||
"""Test the state of the AI Task entity is updated when generating text."""
|
||||
entity = hass.states.get(TEST_ENTITY_ID)
|
||||
assert entity is not None
|
||||
assert entity.state == STATE_UNKNOWN
|
||||
|
||||
result = await async_generate_text(
|
||||
hass,
|
||||
task_name="Test task",
|
||||
entity_id=TEST_ENTITY_ID,
|
||||
instructions="Test prompt",
|
||||
)
|
||||
assert result.text == "Mock result"
|
||||
|
||||
entity = hass.states.get(TEST_ENTITY_ID)
|
||||
assert entity.state == "2025-06-08T16:28:13+00:00"
|
||||
|
||||
assert mock_ai_task_entity.mock_generate_text_tasks
|
||||
task = mock_ai_task_entity.mock_generate_text_tasks[0]
|
||||
assert task.instructions == "Test prompt"
|
||||
@@ -1,84 +0,0 @@
|
||||
"""Test the HTTP API for AI Task integration."""
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.typing import WebSocketGenerator
|
||||
|
||||
|
||||
async def test_ws_preferences(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
init_components: None,
|
||||
) -> None:
|
||||
"""Test preferences via the WebSocket API."""
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
# Get initial preferences
|
||||
await client.send_json_auto_id({"type": "ai_task/preferences/get"})
|
||||
msg = await client.receive_json()
|
||||
assert msg["success"]
|
||||
assert msg["result"] == {
|
||||
"gen_text_entity_id": None,
|
||||
}
|
||||
|
||||
# Set preferences
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "ai_task/preferences/set",
|
||||
"gen_text_entity_id": "ai_task.summary_1",
|
||||
}
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
assert msg["success"]
|
||||
assert msg["result"] == {
|
||||
"gen_text_entity_id": "ai_task.summary_1",
|
||||
}
|
||||
|
||||
# Get updated preferences
|
||||
await client.send_json_auto_id({"type": "ai_task/preferences/get"})
|
||||
msg = await client.receive_json()
|
||||
assert msg["success"]
|
||||
assert msg["result"] == {
|
||||
"gen_text_entity_id": "ai_task.summary_1",
|
||||
}
|
||||
|
||||
# Update an existing preference
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "ai_task/preferences/set",
|
||||
"gen_text_entity_id": "ai_task.summary_2",
|
||||
}
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
assert msg["success"]
|
||||
assert msg["result"] == {
|
||||
"gen_text_entity_id": "ai_task.summary_2",
|
||||
}
|
||||
|
||||
# Get updated preferences
|
||||
await client.send_json_auto_id({"type": "ai_task/preferences/get"})
|
||||
msg = await client.receive_json()
|
||||
assert msg["success"]
|
||||
assert msg["result"] == {
|
||||
"gen_text_entity_id": "ai_task.summary_2",
|
||||
}
|
||||
|
||||
# No preferences set will preserve existing preferences
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "ai_task/preferences/set",
|
||||
}
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
assert msg["success"]
|
||||
assert msg["result"] == {
|
||||
"gen_text_entity_id": "ai_task.summary_2",
|
||||
}
|
||||
|
||||
# Get updated preferences
|
||||
await client.send_json_auto_id({"type": "ai_task/preferences/get"})
|
||||
msg = await client.receive_json()
|
||||
assert msg["success"]
|
||||
assert msg["result"] == {
|
||||
"gen_text_entity_id": "ai_task.summary_2",
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
"""Test initialization of the AI Task component."""
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.ai_task import AITaskPreferences
|
||||
from homeassistant.components.ai_task.const import DATA_PREFERENCES
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .conftest import TEST_ENTITY_ID
|
||||
|
||||
from tests.common import flush_store
|
||||
|
||||
|
||||
async def test_preferences_storage_load(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test that AITaskPreferences are stored and loaded correctly."""
|
||||
preferences = AITaskPreferences(hass)
|
||||
await preferences.async_load()
|
||||
|
||||
# Initial state should be None for entity IDs
|
||||
for key in AITaskPreferences.KEYS:
|
||||
assert getattr(preferences, key) is None, f"Initial {key} should be None"
|
||||
|
||||
new_values = {key: f"ai_task.test_{key}" for key in AITaskPreferences.KEYS}
|
||||
|
||||
preferences.async_set_preferences(**new_values)
|
||||
|
||||
# Verify that current preferences object is updated
|
||||
for key, value in new_values.items():
|
||||
assert getattr(preferences, key) == value, (
|
||||
f"Current {key} should match set value"
|
||||
)
|
||||
|
||||
await flush_store(preferences._store)
|
||||
|
||||
# Create a new preferences instance to test loading from store
|
||||
new_preferences_instance = AITaskPreferences(hass)
|
||||
await new_preferences_instance.async_load()
|
||||
|
||||
for key in AITaskPreferences.KEYS:
|
||||
assert getattr(preferences, key) == getattr(new_preferences_instance, key), (
|
||||
f"Loaded {key} should match saved value"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("set_preferences", "msg_extra"),
|
||||
[
|
||||
(
|
||||
{"gen_text_entity_id": TEST_ENTITY_ID},
|
||||
{},
|
||||
),
|
||||
(
|
||||
{},
|
||||
{"entity_id": TEST_ENTITY_ID},
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_generate_text_service(
|
||||
hass: HomeAssistant,
|
||||
init_components: None,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
set_preferences: dict[str, str | None],
|
||||
msg_extra: dict[str, str],
|
||||
) -> None:
|
||||
"""Test the generate text service."""
|
||||
preferences = hass.data[DATA_PREFERENCES]
|
||||
preferences.async_set_preferences(**set_preferences)
|
||||
|
||||
result = await hass.services.async_call(
|
||||
"ai_task",
|
||||
"generate_text",
|
||||
{
|
||||
"task_name": "Test Name",
|
||||
"instructions": "Test prompt",
|
||||
}
|
||||
| msg_extra,
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
assert result["text"] == "Mock result"
|
||||
@@ -1,123 +0,0 @@
|
||||
"""Test tasks for the AI Task integration."""
|
||||
|
||||
from freezegun import freeze_time
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.ai_task import AITaskEntityFeature, async_generate_text
|
||||
from homeassistant.components.conversation import async_get_chat_log
|
||||
from homeassistant.const import STATE_UNKNOWN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import chat_session
|
||||
|
||||
from .conftest import TEST_ENTITY_ID, MockAITaskEntity
|
||||
|
||||
from tests.typing import WebSocketGenerator
|
||||
|
||||
|
||||
async def test_run_task_preferred_entity(
|
||||
hass: HomeAssistant,
|
||||
init_components: None,
|
||||
mock_ai_task_entity: MockAITaskEntity,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""Test running a task with an unknown entity."""
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError, match="No entity_id provided and no preferred entity set"
|
||||
):
|
||||
await async_generate_text(
|
||||
hass,
|
||||
task_name="Test Task",
|
||||
instructions="Test prompt",
|
||||
)
|
||||
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "ai_task/preferences/set",
|
||||
"gen_text_entity_id": "ai_task.unknown",
|
||||
}
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
assert msg["success"]
|
||||
|
||||
with pytest.raises(ValueError, match="AI Task entity ai_task.unknown not found"):
|
||||
await async_generate_text(
|
||||
hass,
|
||||
task_name="Test Task",
|
||||
instructions="Test prompt",
|
||||
)
|
||||
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "ai_task/preferences/set",
|
||||
"gen_text_entity_id": TEST_ENTITY_ID,
|
||||
}
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
assert msg["success"]
|
||||
|
||||
state = hass.states.get(TEST_ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNKNOWN
|
||||
|
||||
result = await async_generate_text(
|
||||
hass,
|
||||
task_name="Test Task",
|
||||
instructions="Test prompt",
|
||||
)
|
||||
assert result.text == "Mock result"
|
||||
state = hass.states.get(TEST_ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state != STATE_UNKNOWN
|
||||
|
||||
mock_ai_task_entity.supported_features = AITaskEntityFeature(0)
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="AI Task entity ai_task.test_task_entity does not support generating text",
|
||||
):
|
||||
await async_generate_text(
|
||||
hass,
|
||||
task_name="Test Task",
|
||||
instructions="Test prompt",
|
||||
)
|
||||
|
||||
|
||||
async def test_run_text_task_unknown_entity(
|
||||
hass: HomeAssistant,
|
||||
init_components: None,
|
||||
) -> None:
|
||||
"""Test running a text task with an unknown entity."""
|
||||
|
||||
with pytest.raises(
|
||||
ValueError, match="AI Task entity ai_task.unknown_entity not found"
|
||||
):
|
||||
await async_generate_text(
|
||||
hass,
|
||||
task_name="Test Task",
|
||||
entity_id="ai_task.unknown_entity",
|
||||
instructions="Test prompt",
|
||||
)
|
||||
|
||||
|
||||
@freeze_time("2025-06-14 22:59:00")
|
||||
async def test_run_text_task_updates_chat_log(
|
||||
hass: HomeAssistant,
|
||||
init_components: None,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test that running a text task updates the chat log."""
|
||||
result = await async_generate_text(
|
||||
hass,
|
||||
task_name="Test Task",
|
||||
entity_id=TEST_ENTITY_ID,
|
||||
instructions="Test prompt",
|
||||
)
|
||||
assert result.text == "Mock result"
|
||||
|
||||
with (
|
||||
chat_session.async_get_chat_session(hass, result.conversation_id) as session,
|
||||
async_get_chat_log(hass, session) as chat_log,
|
||||
):
|
||||
assert chat_log.content == snapshot
|
||||
@@ -243,12 +243,12 @@ async def test_pipeline_api_audio(
|
||||
event_callback(
|
||||
PipelineEvent(
|
||||
type=PipelineEventType.INTENT_PROGRESS,
|
||||
data={"tts_start_streaming": "1"},
|
||||
data={"tts_start_streaming": True},
|
||||
)
|
||||
)
|
||||
assert mock_client.send_voice_assistant_event.call_args_list[-1].args == (
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS,
|
||||
{"tts_start_streaming": "1"},
|
||||
{"tts_start_streaming": True},
|
||||
)
|
||||
|
||||
event_callback(
|
||||
|
||||
@@ -319,8 +319,8 @@ async def test_entity_ids(hass: HomeAssistant, valid_response: MagicMock) -> Non
|
||||
|
||||
valid_response.assert_called_with(
|
||||
transport_mode=TransportMode.TRUCK,
|
||||
origin=Place(float(ORIGIN_LATITUDE), float(ORIGIN_LONGITUDE)),
|
||||
destination=Place(float(DESTINATION_LATITUDE), float(DESTINATION_LONGITUDE)),
|
||||
origin=Place(ORIGIN_LATITUDE, ORIGIN_LONGITUDE),
|
||||
destination=Place(DESTINATION_LATITUDE, DESTINATION_LONGITUDE),
|
||||
routing_mode=RoutingMode.FAST,
|
||||
arrival_time=None,
|
||||
departure_time=None,
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
"changed_by_id": 0,
|
||||
"based_on": 1,
|
||||
"data": "",
|
||||
"name": "Kitchen Light"
|
||||
"name": ""
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# serializer version: 1
|
||||
# name: test_event_snapshot[event.remote_control_kitchen_light-entry]
|
||||
# name: test_event_snapshot[event.remote_control_switch_1-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -18,7 +18,7 @@
|
||||
'disabled_by': None,
|
||||
'domain': 'event',
|
||||
'entity_category': None,
|
||||
'entity_id': 'event.remote_control_kitchen_light',
|
||||
'entity_id': 'event.remote_control_switch_1',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
@@ -30,7 +30,7 @@
|
||||
}),
|
||||
'original_device_class': <EventDeviceClass.BUTTON: 'button'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Kitchen Light',
|
||||
'original_name': 'Switch 1',
|
||||
'platform': 'homee',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
@@ -40,7 +40,7 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_event_snapshot[event.remote_control_kitchen_light-state]
|
||||
# name: test_event_snapshot[event.remote_control_switch_1-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'button',
|
||||
@@ -50,10 +50,10 @@
|
||||
'lower',
|
||||
'released',
|
||||
]),
|
||||
'friendly_name': 'Remote Control Kitchen Light',
|
||||
'friendly_name': 'Remote Control Switch 1',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'event.remote_control_kitchen_light',
|
||||
'entity_id': 'event.remote_control_switch_1',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
|
||||
@@ -111,23 +111,6 @@ async def test_lock_changed_by(
|
||||
assert hass.states.get("lock.test_lock").attributes["changed_by"] == expected
|
||||
|
||||
|
||||
async def test_lock_changed_by_unknown_user(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_homee: MagicMock,
|
||||
) -> None:
|
||||
"""Test lock changed by entries."""
|
||||
mock_homee.nodes = [build_mock_node("lock.json")]
|
||||
mock_homee.get_node_by_id.return_value = mock_homee.nodes[0]
|
||||
mock_homee.get_user_by_id.return_value = None # Simulate unknown user
|
||||
attribute = mock_homee.nodes[0].attributes[0]
|
||||
attribute.changed_by = 2
|
||||
attribute.changed_by_id = 1
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert hass.states.get("lock.test_lock").attributes["changed_by"] == "user-Unknown"
|
||||
|
||||
|
||||
async def test_lock_snapshot(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user