Compare commits

..

2 Commits

Author SHA1 Message Date
epenet 1929a0d6dd Adjust 2025-06-20 09:59:55 +00:00
epenet 1adab9a982 Remove JuiceNet integration 2025-06-20 09:49:05 +00:00
128 changed files with 1045 additions and 4215 deletions
Generated
-4
View File
@@ -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}
-29
View File
@@ -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."""
-103
View File
@@ -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
-54
View File
@@ -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."
}
}
}
}
}
-71
View File
@@ -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."]
}
-2
View File
@@ -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
+1 -1
View File
@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["homee"],
"quality_scale": "bronze",
"requirements": ["pyHomee==1.2.10"]
"requirements": ["pyHomee==1.2.9"]
}
+1 -7
View File
@@ -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": {
+22 -81
View File
@@ -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)
-124
View File
@@ -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)
+4 -37
View File
@@ -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:
+3 -7
View File
@@ -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()
+4 -3
View File
@@ -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)
+4 -3
View File
@@ -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)
+7 -3
View File
@@ -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)
+3 -3
View File
@@ -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(
(
+4 -3
View File
@@ -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)
+4 -3
View File
@@ -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
+2 -29
View File
@@ -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
+20 -13
View File
@@ -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:
+2 -2
View File
@@ -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:
+5 -37
View File
@@ -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"
+3 -3
View File
@@ -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
+24 -21
View File
@@ -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",
-5
View File
@@ -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."""
-1
View File
@@ -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"
-1
View File
@@ -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",
+7 -10
View File
@@ -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
+7 -10
View File
@@ -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
View File
@@ -1 +0,0 @@
"""Tests for the AI Task integration."""
-127
View File
@@ -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,
}),
])
# ---
-39
View File
@@ -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"
-84
View File
@@ -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",
}
-84
View File
@@ -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"
-123
View File
@@ -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,
+1 -1
View File
@@ -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>,
-17
View File
@@ -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