mirror of
https://github.com/home-assistant/core.git
synced 2025-09-26 21:39:24 +00:00
Compare commits
28 Commits
state_temp
...
ai-task-st
Author | SHA1 | Date | |
---|---|---|---|
![]() |
9bd7ea78f0 | ||
![]() |
e42038742a | ||
![]() |
1fc624c7a7 | ||
![]() |
8641a2141c | ||
![]() |
04cc451c76 | ||
![]() |
a3b03caead | ||
![]() |
ff58c4e564 | ||
![]() |
5ba71a4675 | ||
![]() |
7fb7bc8e50 | ||
![]() |
afa30be64b | ||
![]() |
789eb029fa | ||
![]() |
49d1d781b8 | ||
![]() |
11c75d7ef2 | ||
![]() |
8ef6b62d9a | ||
![]() |
b410b414ec | ||
![]() |
e5f7421703 | ||
![]() |
8330ae2d3a | ||
![]() |
4b162f09bd | ||
![]() |
9c558fabcd | ||
![]() |
5f9cc0a5f6 | ||
![]() |
bc4a322e81 | ||
![]() |
b999c5906e | ||
![]() |
d2825e1c80 | ||
![]() |
419e4f3b1d | ||
![]() |
4a937d2452 | ||
![]() |
01b4a5ceed | ||
![]() |
4e71745c62 | ||
![]() |
6a88ee7a8f |
51
.github/ISSUE_TEMPLATE/task.yml
vendored
Normal file
51
.github/ISSUE_TEMPLATE/task.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
name: Task
|
||||
description: For staff only - Create a task
|
||||
type: Task
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## ⚠️ RESTRICTED ACCESS
|
||||
|
||||
**This form is restricted to Open Home Foundation staff, authorized contributors, and integration code owners only.**
|
||||
|
||||
If you are a community member wanting to contribute, please:
|
||||
- For bug reports: Use the [bug report form](https://github.com/home-assistant/core/issues/new?template=bug_report.yml)
|
||||
- For feature requests: Submit to [Feature Requests](https://github.com/orgs/home-assistant/discussions)
|
||||
|
||||
---
|
||||
|
||||
### For authorized contributors
|
||||
|
||||
Use this form to create tasks for development work, improvements, or other actionable items that need to be tracked.
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Task description
|
||||
description: |
|
||||
Provide a clear and detailed description of the task that needs to be accomplished.
|
||||
|
||||
Be specific about what needs to be done, why it's important, and any constraints or requirements.
|
||||
placeholder: |
|
||||
Describe the task, including:
|
||||
- What needs to be done
|
||||
- Why this task is needed
|
||||
- Expected outcome
|
||||
- Any constraints or requirements
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: additional_context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: |
|
||||
Any additional information, links, research, or context that would be helpful.
|
||||
|
||||
Include links to related issues, research, prototypes, roadmap opportunities etc.
|
||||
placeholder: |
|
||||
- Roadmap opportunity: [links]
|
||||
- Feature request: [link]
|
||||
- Technical design documents: [link]
|
||||
- Prototype/mockup: [link]
|
||||
validations:
|
||||
required: false
|
84
.github/workflows/restrict-task-creation.yml
vendored
Normal file
84
.github/workflows/restrict-task-creation.yml
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
name: Restrict task creation
|
||||
|
||||
# yamllint disable-line rule:truthy
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
check-authorization:
|
||||
runs-on: ubuntu-latest
|
||||
# Only run if this is a Task issue type (from the issue form)
|
||||
if: github.event.issue.issue_type == 'Task'
|
||||
steps:
|
||||
- name: Check if user is authorized
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const issueAuthor = context.payload.issue.user.login;
|
||||
|
||||
// First check if user is an organization member
|
||||
try {
|
||||
await github.rest.orgs.checkMembershipForUser({
|
||||
org: 'home-assistant',
|
||||
username: issueAuthor
|
||||
});
|
||||
console.log(`✅ ${issueAuthor} is an organization member`);
|
||||
return; // Authorized, no need to check further
|
||||
} catch (error) {
|
||||
console.log(`ℹ️ ${issueAuthor} is not an organization member, checking codeowners...`);
|
||||
}
|
||||
|
||||
// If not an org member, check if they're a codeowner
|
||||
try {
|
||||
// Fetch CODEOWNERS file from the repository
|
||||
const { data: codeownersFile } = await github.rest.repos.getContent({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
path: 'CODEOWNERS',
|
||||
ref: 'dev'
|
||||
});
|
||||
|
||||
// Decode the content (it's base64 encoded)
|
||||
const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf-8');
|
||||
|
||||
// Check if the issue author is mentioned in CODEOWNERS
|
||||
// GitHub usernames in CODEOWNERS are prefixed with @
|
||||
if (codeownersContent.includes(`@${issueAuthor}`)) {
|
||||
console.log(`✅ ${issueAuthor} is a integration code owner`);
|
||||
return; // Authorized
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking CODEOWNERS:', error);
|
||||
}
|
||||
|
||||
// If we reach here, user is not authorized
|
||||
console.log(`❌ ${issueAuthor} is not authorized to create Task issues`);
|
||||
|
||||
// Close the issue with a comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: `Hi @${issueAuthor}, thank you for your contribution!\n\n` +
|
||||
`Task issues are restricted to Open Home Foundation staff, authorized contributors, and integration code owners.\n\n` +
|
||||
`If you would like to:\n` +
|
||||
`- Report a bug: Please use the [bug report form](https://github.com/home-assistant/core/issues/new?template=bug_report.yml)\n` +
|
||||
`- Request a feature: Please submit to [Feature Requests](https://github.com/orgs/home-assistant/discussions)\n\n` +
|
||||
`If you believe you should have access to create Task issues, please contact the maintainers.`
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
state: 'closed'
|
||||
});
|
||||
|
||||
// Add a label to indicate this was auto-closed
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['auto-closed']
|
||||
});
|
@@ -1,11 +1,12 @@
|
||||
"""Integration to offer AI tasks to Home Assistant."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.const import ATTR_ENTITY_ID, CONF_DESCRIPTION, CONF_SELECTOR
|
||||
from homeassistant.core import (
|
||||
HassJobType,
|
||||
HomeAssistant,
|
||||
@@ -14,12 +15,14 @@ from homeassistant.core import (
|
||||
SupportsResponse,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv, storage
|
||||
from homeassistant.helpers import config_validation as cv, selector, storage
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType
|
||||
|
||||
from .const import (
|
||||
ATTR_INSTRUCTIONS,
|
||||
ATTR_REQUIRED,
|
||||
ATTR_STRUCTURE,
|
||||
ATTR_TASK_NAME,
|
||||
DATA_COMPONENT,
|
||||
DATA_PREFERENCES,
|
||||
@@ -47,6 +50,27 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
STRUCTURE_FIELD_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_DESCRIPTION): str,
|
||||
vol.Optional(ATTR_REQUIRED): bool,
|
||||
vol.Required(CONF_SELECTOR): selector.validate_selector,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _validate_structure_fields(value: dict[str, Any]) -> vol.Schema:
|
||||
"""Validate the structure fields as a voluptuous Schema."""
|
||||
if not isinstance(value, dict):
|
||||
raise vol.Invalid("Structure must be a dictionary")
|
||||
fields = {}
|
||||
for k, v in value.items():
|
||||
field_class = vol.Required if v.get(ATTR_REQUIRED, False) else vol.Optional
|
||||
fields[field_class(k, description=v.get(CONF_DESCRIPTION))] = selector.selector(
|
||||
v[CONF_SELECTOR]
|
||||
)
|
||||
return vol.Schema(fields, extra=vol.PREVENT_EXTRA)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Register the process service."""
|
||||
@@ -64,6 +88,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
vol.Required(ATTR_TASK_NAME): cv.string,
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.entity_id,
|
||||
vol.Required(ATTR_INSTRUCTIONS): cv.string,
|
||||
vol.Optional(ATTR_STRUCTURE): vol.All(
|
||||
vol.Schema({str: STRUCTURE_FIELD_SCHEMA}),
|
||||
_validate_structure_fields,
|
||||
),
|
||||
}
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
|
@@ -21,6 +21,8 @@ SERVICE_GENERATE_DATA = "generate_data"
|
||||
|
||||
ATTR_INSTRUCTIONS: Final = "instructions"
|
||||
ATTR_TASK_NAME: Final = "task_name"
|
||||
ATTR_STRUCTURE: Final = "structure"
|
||||
ATTR_REQUIRED: Final = "required"
|
||||
|
||||
DEFAULT_SYSTEM_PROMPT = (
|
||||
"You are a Home Assistant expert and help users with their tasks."
|
||||
|
@@ -17,3 +17,9 @@ generate_data:
|
||||
domain: ai_task
|
||||
supported_features:
|
||||
- ai_task.AITaskEntityFeature.GENERATE_DATA
|
||||
structure:
|
||||
advanced: true
|
||||
required: false
|
||||
example: '{ "name": { "selector": { "text": }, "description": "Name of the user", "required": "True" } } }, "age": { "selector": { "number": }, "description": "Age of the user" } }'
|
||||
selector:
|
||||
object:
|
||||
|
@@ -15,6 +15,10 @@
|
||||
"entity_id": {
|
||||
"name": "Entity ID",
|
||||
"description": "Entity ID to run the task on. If not provided, the preferred entity will be used."
|
||||
},
|
||||
"structure": {
|
||||
"name": "Structured output",
|
||||
"description": "When set, the AI Task will output fields with this in structure. The structure is a dictionary where the keys are the field names and the values contain a 'description', a 'selector', and an optional 'required' field."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -5,6 +5,8 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
@@ -17,6 +19,7 @@ async def async_generate_data(
|
||||
task_name: str,
|
||||
entity_id: str | None = None,
|
||||
instructions: str,
|
||||
structure: vol.Schema | None = None,
|
||||
) -> GenDataTaskResult:
|
||||
"""Run a task in the AI Task integration."""
|
||||
if entity_id is None:
|
||||
@@ -38,6 +41,7 @@ async def async_generate_data(
|
||||
GenDataTask(
|
||||
name=task_name,
|
||||
instructions=instructions,
|
||||
structure=structure,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -52,6 +56,9 @@ class GenDataTask:
|
||||
instructions: str
|
||||
"""Instructions on what needs to be done."""
|
||||
|
||||
structure: vol.Schema | None = None
|
||||
"""Optional structure for the data to be generated."""
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Return task as a string."""
|
||||
return f"<GenDataTask {self.name}: {id(self)}>"
|
||||
|
@@ -5,26 +5,18 @@ from __future__ import annotations
|
||||
from asyncio import timeout
|
||||
import logging
|
||||
|
||||
from androidtvremote2 import (
|
||||
AndroidTVRemote,
|
||||
CannotConnect,
|
||||
ConnectionClosed,
|
||||
InvalidAuth,
|
||||
)
|
||||
from androidtvremote2 import CannotConnect, ConnectionClosed, InvalidAuth
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP, Platform
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
|
||||
from .helpers import create_api, get_enable_ime
|
||||
from .helpers import AndroidTVRemoteConfigEntry, create_api, get_enable_ime
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.REMOTE]
|
||||
|
||||
AndroidTVRemoteConfigEntry = ConfigEntry[AndroidTVRemote]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: AndroidTVRemoteConfigEntry
|
||||
@@ -82,13 +74,17 @@ async def async_setup_entry(
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: AndroidTVRemoteConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
_LOGGER.debug("async_unload_entry: %s", entry.data)
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
async def async_update_options(
|
||||
hass: HomeAssistant, entry: AndroidTVRemoteConfigEntry
|
||||
) -> None:
|
||||
"""Handle options update."""
|
||||
_LOGGER.debug(
|
||||
"async_update_options: data: %s options: %s", entry.data, entry.options
|
||||
|
@@ -16,7 +16,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_REAUTH,
|
||||
ConfigEntry,
|
||||
SOURCE_RECONFIGURE,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
@@ -33,7 +33,7 @@ from homeassistant.helpers.selector import (
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from .const import CONF_APP_ICON, CONF_APP_NAME, CONF_APPS, CONF_ENABLE_IME, DOMAIN
|
||||
from .helpers import create_api, get_enable_ime
|
||||
from .helpers import AndroidTVRemoteConfigEntry, create_api, get_enable_ime
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,12 +41,6 @@ APPS_NEW_ID = "NewApp"
|
||||
CONF_APP_DELETE = "app_delete"
|
||||
CONF_APP_ID = "app_id"
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("host"): str,
|
||||
}
|
||||
)
|
||||
|
||||
STEP_PAIR_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("pin"): str,
|
||||
@@ -67,7 +61,7 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
"""Handle the initial and reconfigure step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
self.host = user_input[CONF_HOST]
|
||||
@@ -76,15 +70,32 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
await api.async_generate_cert_if_missing()
|
||||
self.name, self.mac = await api.async_get_name_and_mac()
|
||||
await self.async_set_unique_id(format_mac(self.mac))
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
self._abort_if_unique_id_mismatch()
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reconfigure_entry(),
|
||||
data={
|
||||
CONF_HOST: self.host,
|
||||
CONF_NAME: self.name,
|
||||
CONF_MAC: self.mac,
|
||||
},
|
||||
)
|
||||
self._abort_if_unique_id_configured(updates={CONF_HOST: self.host})
|
||||
return await self._async_start_pair()
|
||||
except (CannotConnect, ConnectionClosed):
|
||||
# Likely invalid IP address or device is network unreachable. Stay
|
||||
# in the user step allowing the user to enter a different host.
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
user_input = {}
|
||||
default_host = user_input.get(CONF_HOST, vol.UNDEFINED)
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
default_host = self._get_reconfigure_entry().data[CONF_HOST]
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
step_id="reconfigure" if self.source == SOURCE_RECONFIGURE else "user",
|
||||
data_schema=vol.Schema(
|
||||
{vol.Required(CONF_HOST, default=default_host): str}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -217,10 +228,16 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration."""
|
||||
return await self.async_step_user(user_input)
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: AndroidTVRemoteConfigEntry,
|
||||
) -> AndroidTVRemoteOptionsFlowHandler:
|
||||
"""Create the options flow."""
|
||||
return AndroidTVRemoteOptionsFlowHandler(config_entry)
|
||||
@@ -229,7 +246,7 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
class AndroidTVRemoteOptionsFlowHandler(OptionsFlow):
|
||||
"""Android TV Remote options flow."""
|
||||
|
||||
def __init__(self, config_entry: ConfigEntry) -> None:
|
||||
def __init__(self, config_entry: AndroidTVRemoteConfigEntry) -> None:
|
||||
"""Initialize options flow."""
|
||||
self._apps: dict[str, Any] = dict(config_entry.options.get(CONF_APPS, {}))
|
||||
self._conf_app_id: str | None = None
|
||||
|
@@ -8,7 +8,7 @@ from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_HOST, CONF_MAC
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import AndroidTVRemoteConfigEntry
|
||||
from .helpers import AndroidTVRemoteConfigEntry
|
||||
|
||||
TO_REDACT = {CONF_HOST, CONF_MAC}
|
||||
|
||||
|
@@ -6,7 +6,6 @@ from typing import Any
|
||||
|
||||
from androidtvremote2 import AndroidTVRemote, ConnectionClosed
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -14,6 +13,7 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, Device
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .const import CONF_APPS, DOMAIN
|
||||
from .helpers import AndroidTVRemoteConfigEntry
|
||||
|
||||
|
||||
class AndroidTVRemoteBaseEntity(Entity):
|
||||
@@ -23,7 +23,9 @@ class AndroidTVRemoteBaseEntity(Entity):
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, api: AndroidTVRemote, config_entry: ConfigEntry) -> None:
|
||||
def __init__(
|
||||
self, api: AndroidTVRemote, config_entry: AndroidTVRemoteConfigEntry
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
self._api = api
|
||||
self._host = config_entry.data[CONF_HOST]
|
||||
|
@@ -10,6 +10,8 @@ from homeassistant.helpers.storage import STORAGE_DIR
|
||||
|
||||
from .const import CONF_ENABLE_IME, CONF_ENABLE_IME_DEFAULT_VALUE
|
||||
|
||||
AndroidTVRemoteConfigEntry = ConfigEntry[AndroidTVRemote]
|
||||
|
||||
|
||||
def create_api(hass: HomeAssistant, host: str, enable_ime: bool) -> AndroidTVRemote:
|
||||
"""Create an AndroidTVRemote instance."""
|
||||
@@ -23,6 +25,6 @@ def create_api(hass: HomeAssistant, host: str, enable_ime: bool) -> AndroidTVRem
|
||||
)
|
||||
|
||||
|
||||
def get_enable_ime(entry: ConfigEntry) -> bool:
|
||||
def get_enable_ime(entry: AndroidTVRemoteConfigEntry) -> bool:
|
||||
"""Get value of enable_ime option or its default value."""
|
||||
return entry.options.get(CONF_ENABLE_IME, CONF_ENABLE_IME_DEFAULT_VALUE)
|
||||
|
@@ -7,6 +7,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["androidtvremote2"],
|
||||
"requirements": ["androidtvremote2==0.2.2"],
|
||||
"requirements": ["androidtvremote2==0.2.3"],
|
||||
"zeroconf": ["_androidtvremote2._tcp.local."]
|
||||
}
|
||||
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from androidtvremote2 import AndroidTVRemote, ConnectionClosed
|
||||
from androidtvremote2 import AndroidTVRemote, ConnectionClosed, VolumeInfo
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
BrowseMedia,
|
||||
@@ -20,9 +20,9 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AndroidTVRemoteConfigEntry
|
||||
from .const import CONF_APP_ICON, CONF_APP_NAME, DOMAIN
|
||||
from .entity import AndroidTVRemoteBaseEntity
|
||||
from .helpers import AndroidTVRemoteConfigEntry
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -75,13 +75,11 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt
|
||||
else current_app
|
||||
)
|
||||
|
||||
def _update_volume_info(self, volume_info: dict[str, str | bool]) -> None:
|
||||
def _update_volume_info(self, volume_info: VolumeInfo) -> None:
|
||||
"""Update volume info."""
|
||||
if volume_info.get("max"):
|
||||
self._attr_volume_level = int(volume_info["level"]) / int(
|
||||
volume_info["max"]
|
||||
)
|
||||
self._attr_is_volume_muted = bool(volume_info["muted"])
|
||||
self._attr_volume_level = volume_info["level"] / volume_info["max"]
|
||||
self._attr_is_volume_muted = volume_info["muted"]
|
||||
else:
|
||||
self._attr_volume_level = None
|
||||
self._attr_is_volume_muted = None
|
||||
@@ -93,7 +91,7 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def _volume_info_updated(self, volume_info: dict[str, str | bool]) -> None:
|
||||
def _volume_info_updated(self, volume_info: VolumeInfo) -> None:
|
||||
"""Update the state when the volume info changes."""
|
||||
self._update_volume_info(volume_info)
|
||||
self.async_write_ha_state()
|
||||
@@ -102,8 +100,10 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt
|
||||
"""Register callbacks."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
self._update_current_app(self._api.current_app)
|
||||
self._update_volume_info(self._api.volume_info)
|
||||
if self._api.current_app is not None:
|
||||
self._update_current_app(self._api.current_app)
|
||||
if self._api.volume_info is not None:
|
||||
self._update_volume_info(self._api.volume_info)
|
||||
|
||||
self._api.add_current_app_updated_callback(self._current_app_updated)
|
||||
self._api.add_volume_info_updated_callback(self._volume_info_updated)
|
||||
|
@@ -20,9 +20,9 @@ from homeassistant.components.remote import (
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AndroidTVRemoteConfigEntry
|
||||
from .const import CONF_APP_NAME
|
||||
from .entity import AndroidTVRemoteBaseEntity
|
||||
from .helpers import AndroidTVRemoteConfigEntry
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -63,7 +63,8 @@ class AndroidTVRemoteEntity(AndroidTVRemoteBaseEntity, RemoteEntity):
|
||||
self._attr_activity_list = [
|
||||
app.get(CONF_APP_NAME, "") for app in self._apps.values()
|
||||
]
|
||||
self._update_current_app(self._api.current_app)
|
||||
if self._api.current_app is not None:
|
||||
self._update_current_app(self._api.current_app)
|
||||
self._api.add_current_app_updated_callback(self._current_app_updated)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
|
@@ -6,6 +6,18 @@
|
||||
"description": "Enter the IP address of the Android TV you want to add to Home Assistant. It will turn on and a pairing code will be displayed on it that you will need to enter in the next screen.",
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of the Android TV device."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"description": "Update the IP address of this previously configured Android TV device.",
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of the Android TV device."
|
||||
}
|
||||
},
|
||||
"zeroconf_confirm": {
|
||||
@@ -16,6 +28,9 @@
|
||||
"description": "Enter the pairing code displayed on the Android TV ({name}).",
|
||||
"data": {
|
||||
"pin": "[%key:common::config_flow::data::pin%]"
|
||||
},
|
||||
"data_description": {
|
||||
"pin": "Pairing code displayed on the Android TV device."
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
@@ -32,7 +47,9 @@
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unique_id_mismatch": "Please ensure you reconfigure against the same device."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
@@ -40,7 +57,11 @@
|
||||
"init": {
|
||||
"data": {
|
||||
"apps": "Configure applications list",
|
||||
"enable_ime": "Enable IME. Needed for getting the current app. Disable for devices that show 'Use keyboard on mobile device screen' instead of the on screen keyboard."
|
||||
"enable_ime": "Enable IME"
|
||||
},
|
||||
"data_description": {
|
||||
"apps": "Here you can define the list of applications, specify names and icons that will be displayed in the UI.",
|
||||
"enable_ime": "Enable this option to be able to get the current app name and send text as keyboard input. Disable it for devices that show 'Use keyboard on mobile device screen' instead of the on-screen keyboard."
|
||||
}
|
||||
},
|
||||
"apps": {
|
||||
@@ -53,8 +74,10 @@
|
||||
"app_delete": "Check to delete this application"
|
||||
},
|
||||
"data_description": {
|
||||
"app_name": "Name of the application as you would like it to be displayed in Home Assistant.",
|
||||
"app_id": "E.g. com.plexapp.android for https://play.google.com/store/apps/details?id=com.plexapp.android",
|
||||
"app_icon": "Image URL. From the Play Store app page, right click on the icon and select 'Copy image address' and then paste it here. Alternatively, download the image, upload it under /config/www/ and use the URL /local/filename"
|
||||
"app_icon": "Image URL. From the Play Store app page, right click on the icon and select 'Copy image address' and then paste it here. Alternatively, download the image, upload it under /config/www/ and use the URL /local/filename",
|
||||
"app_delete": "Check this box to delete the application from the list."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -72,7 +72,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
{
|
||||
vol.Optional("message"): str,
|
||||
vol.Optional("media_id"): _media_id_validator,
|
||||
vol.Optional("preannounce"): bool,
|
||||
vol.Optional("preannounce", default=True): bool,
|
||||
vol.Optional("preannounce_media_id"): _media_id_validator,
|
||||
}
|
||||
),
|
||||
@@ -89,7 +89,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
{
|
||||
vol.Optional("start_message"): str,
|
||||
vol.Optional("start_media_id"): _media_id_validator,
|
||||
vol.Optional("preannounce"): bool,
|
||||
vol.Optional("preannounce", default=True): bool,
|
||||
vol.Optional("preannounce_media_id"): _media_id_validator,
|
||||
vol.Optional("extra_system_prompt"): str,
|
||||
}
|
||||
@@ -114,7 +114,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
ask_question_args = {
|
||||
"question": call.data.get("question"),
|
||||
"question_media_id": call.data.get("question_media_id"),
|
||||
"preannounce": call.data.get("preannounce", False),
|
||||
"preannounce": call.data.get("preannounce", True),
|
||||
"answers": call.data.get("answers"),
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
vol.Required(ATTR_ENTITY_ID): cv.entity_domain(DOMAIN),
|
||||
vol.Optional("question"): str,
|
||||
vol.Optional("question_media_id"): _media_id_validator,
|
||||
vol.Optional("preannounce"): bool,
|
||||
vol.Optional("preannounce", default=True): bool,
|
||||
vol.Optional("preannounce_media_id"): _media_id_validator,
|
||||
vol.Optional("answers"): [
|
||||
{
|
||||
|
@@ -63,6 +63,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) ->
|
||||
coordinator = entry.runtime_data
|
||||
coordinator.async_cancel_token_refresh()
|
||||
coordinator.async_cancel_firmware_refresh()
|
||||
coordinator.async_cancel_mac_verification()
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
|
@@ -7,7 +7,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyenphase"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pyenphase==2.1.0"],
|
||||
"requirements": ["pyenphase==2.2.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_enphase-envoy._tcp.local."
|
||||
|
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from functools import partial
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
@@ -37,11 +38,13 @@ from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
CONF_PROMPT,
|
||||
DEFAULT_AI_TASK_NAME,
|
||||
DEFAULT_TITLE,
|
||||
DEFAULT_TTS_NAME,
|
||||
DOMAIN,
|
||||
FILE_POLLING_INTERVAL_SECONDS,
|
||||
LOGGER,
|
||||
RECOMMENDED_AI_TASK_OPTIONS,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
RECOMMENDED_TTS_OPTIONS,
|
||||
TIMEOUT_MILLIS,
|
||||
@@ -53,6 +56,7 @@ CONF_FILENAMES = "filenames"
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
PLATFORMS = (
|
||||
Platform.AI_TASK,
|
||||
Platform.CONVERSATION,
|
||||
Platform.TTS,
|
||||
)
|
||||
@@ -187,11 +191,9 @@ async def async_setup_entry(
|
||||
"""Set up Google Generative AI Conversation from a config entry."""
|
||||
|
||||
try:
|
||||
|
||||
def _init_client() -> Client:
|
||||
return Client(api_key=entry.data[CONF_API_KEY])
|
||||
|
||||
client = await hass.async_add_executor_job(_init_client)
|
||||
client = await hass.async_add_executor_job(
|
||||
partial(Client, api_key=entry.data[CONF_API_KEY])
|
||||
)
|
||||
await client.aio.models.get(
|
||||
model=RECOMMENDED_CHAT_MODEL,
|
||||
config={"http_options": {"timeout": TIMEOUT_MILLIS}},
|
||||
@@ -350,6 +352,19 @@ async def async_migrate_entry(
|
||||
|
||||
hass.config_entries.async_update_entry(entry, minor_version=2)
|
||||
|
||||
if entry.version == 2 and entry.minor_version == 2:
|
||||
# Add AI Task subentry with default options
|
||||
hass.config_entries.async_add_subentry(
|
||||
entry,
|
||||
ConfigSubentry(
|
||||
data=MappingProxyType(RECOMMENDED_AI_TASK_OPTIONS),
|
||||
subentry_type="ai_task_data",
|
||||
title=DEFAULT_AI_TASK_NAME,
|
||||
unique_id=None,
|
||||
),
|
||||
)
|
||||
hass.config_entries.async_update_entry(entry, minor_version=3)
|
||||
|
||||
LOGGER.debug(
|
||||
"Migration to version %s:%s successful", entry.version, entry.minor_version
|
||||
)
|
||||
|
@@ -0,0 +1,57 @@
|
||||
"""AI Task integration for Google Generative AI Conversation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components import ai_task, conversation
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import LOGGER
|
||||
from .entity import ERROR_GETTING_RESPONSE, GoogleGenerativeAILLMBaseEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up AI Task entities."""
|
||||
for subentry in config_entry.subentries.values():
|
||||
if subentry.subentry_type != "ai_task_data":
|
||||
continue
|
||||
|
||||
async_add_entities(
|
||||
[GoogleGenerativeAITaskEntity(config_entry, subentry)],
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
)
|
||||
|
||||
|
||||
class GoogleGenerativeAITaskEntity(
|
||||
ai_task.AITaskEntity,
|
||||
GoogleGenerativeAILLMBaseEntity,
|
||||
):
|
||||
"""Google Generative AI AI Task entity."""
|
||||
|
||||
_attr_supported_features = ai_task.AITaskEntityFeature.GENERATE_DATA
|
||||
|
||||
async def _async_generate_data(
|
||||
self,
|
||||
task: ai_task.GenDataTask,
|
||||
chat_log: conversation.ChatLog,
|
||||
) -> ai_task.GenDataTaskResult:
|
||||
"""Handle a generate data 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.GenDataTaskResult(
|
||||
conversation_id=chat_log.conversation_id,
|
||||
data=chat_log.content[-1].content or "",
|
||||
)
|
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from functools import partial
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
@@ -46,10 +47,12 @@ from .const import (
|
||||
CONF_TOP_K,
|
||||
CONF_TOP_P,
|
||||
CONF_USE_GOOGLE_SEARCH_TOOL,
|
||||
DEFAULT_AI_TASK_NAME,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
DEFAULT_TITLE,
|
||||
DEFAULT_TTS_NAME,
|
||||
DOMAIN,
|
||||
RECOMMENDED_AI_TASK_OPTIONS,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
RECOMMENDED_CONVERSATION_OPTIONS,
|
||||
RECOMMENDED_HARM_BLOCK_THRESHOLD,
|
||||
@@ -72,12 +75,14 @@ STEP_API_DATA_SCHEMA = vol.Schema(
|
||||
)
|
||||
|
||||
|
||||
async def validate_input(data: dict[str, Any]) -> None:
|
||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
|
||||
"""Validate the user input allows us to connect.
|
||||
|
||||
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
|
||||
"""
|
||||
client = genai.Client(api_key=data[CONF_API_KEY])
|
||||
client = await hass.async_add_executor_job(
|
||||
partial(genai.Client, api_key=data[CONF_API_KEY])
|
||||
)
|
||||
await client.aio.models.list(
|
||||
config={
|
||||
"http_options": {
|
||||
@@ -92,7 +97,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Google Generative AI Conversation."""
|
||||
|
||||
VERSION = 2
|
||||
MINOR_VERSION = 2
|
||||
MINOR_VERSION = 3
|
||||
|
||||
async def async_step_api(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -102,7 +107,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match(user_input)
|
||||
try:
|
||||
await validate_input(user_input)
|
||||
await validate_input(self.hass, user_input)
|
||||
except (APIError, Timeout) as err:
|
||||
if isinstance(err, ClientError) and "API_KEY_INVALID" in str(err):
|
||||
errors["base"] = "invalid_auth"
|
||||
@@ -133,6 +138,12 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"title": DEFAULT_TTS_NAME,
|
||||
"unique_id": None,
|
||||
},
|
||||
{
|
||||
"subentry_type": "ai_task_data",
|
||||
"data": RECOMMENDED_AI_TASK_OPTIONS,
|
||||
"title": DEFAULT_AI_TASK_NAME,
|
||||
"unique_id": None,
|
||||
},
|
||||
],
|
||||
)
|
||||
return self.async_show_form(
|
||||
@@ -181,6 +192,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return {
|
||||
"conversation": LLMSubentryFlowHandler,
|
||||
"tts": LLMSubentryFlowHandler,
|
||||
"ai_task_data": LLMSubentryFlowHandler,
|
||||
}
|
||||
|
||||
|
||||
@@ -214,6 +226,8 @@ class LLMSubentryFlowHandler(ConfigSubentryFlow):
|
||||
options: dict[str, Any]
|
||||
if self._subentry_type == "tts":
|
||||
options = RECOMMENDED_TTS_OPTIONS.copy()
|
||||
elif self._subentry_type == "ai_task_data":
|
||||
options = RECOMMENDED_AI_TASK_OPTIONS.copy()
|
||||
else:
|
||||
options = RECOMMENDED_CONVERSATION_OPTIONS.copy()
|
||||
else:
|
||||
@@ -288,6 +302,8 @@ async def google_generative_ai_config_option_schema(
|
||||
default_name = options[CONF_NAME]
|
||||
elif subentry_type == "tts":
|
||||
default_name = DEFAULT_TTS_NAME
|
||||
elif subentry_type == "ai_task_data":
|
||||
default_name = DEFAULT_AI_TASK_NAME
|
||||
else:
|
||||
default_name = DEFAULT_CONVERSATION_NAME
|
||||
schema: dict[vol.Required | vol.Optional, Any] = {
|
||||
@@ -315,6 +331,7 @@ async def google_generative_ai_config_option_schema(
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
schema.update(
|
||||
{
|
||||
vol.Required(
|
||||
@@ -443,4 +460,5 @@ async def google_generative_ai_config_option_schema(
|
||||
): bool,
|
||||
}
|
||||
)
|
||||
|
||||
return schema
|
||||
|
@@ -12,6 +12,7 @@ CONF_PROMPT = "prompt"
|
||||
|
||||
DEFAULT_CONVERSATION_NAME = "Google AI Conversation"
|
||||
DEFAULT_TTS_NAME = "Google AI TTS"
|
||||
DEFAULT_AI_TASK_NAME = "Google AI Task"
|
||||
|
||||
CONF_RECOMMENDED = "recommended"
|
||||
CONF_CHAT_MODEL = "chat_model"
|
||||
@@ -35,6 +36,7 @@ RECOMMENDED_USE_GOOGLE_SEARCH_TOOL = False
|
||||
|
||||
TIMEOUT_MILLIS = 10000
|
||||
FILE_POLLING_INTERVAL_SECONDS = 0.05
|
||||
|
||||
RECOMMENDED_CONVERSATION_OPTIONS = {
|
||||
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
|
||||
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
|
||||
@@ -44,3 +46,7 @@ RECOMMENDED_CONVERSATION_OPTIONS = {
|
||||
RECOMMENDED_TTS_OPTIONS = {
|
||||
CONF_RECOMMENDED: True,
|
||||
}
|
||||
|
||||
RECOMMENDED_AI_TASK_OPTIONS = {
|
||||
CONF_RECOMMENDED: True,
|
||||
}
|
||||
|
@@ -88,6 +88,34 @@
|
||||
"entry_not_loaded": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::abort::entry_not_loaded%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
}
|
||||
},
|
||||
"ai_task_data": {
|
||||
"initiate_flow": {
|
||||
"user": "Add Generate data with AI service",
|
||||
"reconfigure": "Reconfigure Generate data with AI service"
|
||||
},
|
||||
"entry_type": "Generate data with AI service",
|
||||
"step": {
|
||||
"set_options": {
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"recommended": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::recommended%]",
|
||||
"chat_model": "[%key:common::generic::model%]",
|
||||
"temperature": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::temperature%]",
|
||||
"top_p": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_p%]",
|
||||
"top_k": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_k%]",
|
||||
"max_tokens": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::max_tokens%]",
|
||||
"harassment_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::harassment_block_threshold%]",
|
||||
"hate_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::hate_block_threshold%]",
|
||||
"sexual_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::sexual_block_threshold%]",
|
||||
"dangerous_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::dangerous_block_threshold%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"entry_not_loaded": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::abort::entry_not_loaded%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from phone_modem import PhoneModem
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
from homeassistant.components.sensor import RestoreSensor
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, STATE_IDLE
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
@@ -40,7 +40,7 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
class ModemCalleridSensor(SensorEntity):
|
||||
class ModemCalleridSensor(RestoreSensor):
|
||||
"""Implementation of USB modem caller ID sensor."""
|
||||
|
||||
_attr_should_poll = False
|
||||
@@ -62,9 +62,21 @@ class ModemCalleridSensor(SensorEntity):
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Call when the modem sensor is added to Home Assistant."""
|
||||
self.api.registercallback(self._async_incoming_call)
|
||||
await super().async_added_to_hass()
|
||||
|
||||
if (last_state := await self.async_get_last_state()) is not None:
|
||||
self._attr_extra_state_attributes[CID.CID_NAME] = last_state.attributes.get(
|
||||
CID.CID_NAME, ""
|
||||
)
|
||||
self._attr_extra_state_attributes[CID.CID_NUMBER] = (
|
||||
last_state.attributes.get(CID.CID_NUMBER, "")
|
||||
)
|
||||
self._attr_extra_state_attributes[CID.CID_TIME] = last_state.attributes.get(
|
||||
CID.CID_TIME, 0
|
||||
)
|
||||
|
||||
self.api.registercallback(self._async_incoming_call)
|
||||
|
||||
@callback
|
||||
def _async_incoming_call(self, new_state: str) -> None:
|
||||
"""Handle new states."""
|
||||
|
@@ -24,6 +24,8 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from .const import DOMAIN
|
||||
from .coordinator import OpowerConfigEntry, OpowerCoordinator
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class OpowerEntityDescription(SensorEntityDescription):
|
||||
@@ -38,7 +40,7 @@ class OpowerEntityDescription(SensorEntityDescription):
|
||||
ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = (
|
||||
OpowerEntityDescription(
|
||||
key="elec_usage_to_date",
|
||||
name="Current bill electric usage to date",
|
||||
translation_key="elec_usage_to_date",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
# Not TOTAL_INCREASING because it can decrease for accounts with solar
|
||||
@@ -48,7 +50,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = (
|
||||
),
|
||||
OpowerEntityDescription(
|
||||
key="elec_forecasted_usage",
|
||||
name="Current bill electric forecasted usage",
|
||||
translation_key="elec_forecasted_usage",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
@@ -57,7 +59,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = (
|
||||
),
|
||||
OpowerEntityDescription(
|
||||
key="elec_typical_usage",
|
||||
name="Typical monthly electric usage",
|
||||
translation_key="elec_typical_usage",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
@@ -66,7 +68,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = (
|
||||
),
|
||||
OpowerEntityDescription(
|
||||
key="elec_cost_to_date",
|
||||
name="Current bill electric cost to date",
|
||||
translation_key="elec_cost_to_date",
|
||||
device_class=SensorDeviceClass.MONETARY,
|
||||
native_unit_of_measurement="USD",
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
@@ -75,7 +77,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = (
|
||||
),
|
||||
OpowerEntityDescription(
|
||||
key="elec_forecasted_cost",
|
||||
name="Current bill electric forecasted cost",
|
||||
translation_key="elec_forecasted_cost",
|
||||
device_class=SensorDeviceClass.MONETARY,
|
||||
native_unit_of_measurement="USD",
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
@@ -84,7 +86,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = (
|
||||
),
|
||||
OpowerEntityDescription(
|
||||
key="elec_typical_cost",
|
||||
name="Typical monthly electric cost",
|
||||
translation_key="elec_typical_cost",
|
||||
device_class=SensorDeviceClass.MONETARY,
|
||||
native_unit_of_measurement="USD",
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
@@ -93,7 +95,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = (
|
||||
),
|
||||
OpowerEntityDescription(
|
||||
key="elec_start_date",
|
||||
name="Current bill electric start date",
|
||||
translation_key="elec_start_date",
|
||||
device_class=SensorDeviceClass.DATE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
@@ -101,7 +103,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = (
|
||||
),
|
||||
OpowerEntityDescription(
|
||||
key="elec_end_date",
|
||||
name="Current bill electric end date",
|
||||
translation_key="elec_end_date",
|
||||
device_class=SensorDeviceClass.DATE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
@@ -111,7 +113,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = (
|
||||
GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
|
||||
OpowerEntityDescription(
|
||||
key="gas_usage_to_date",
|
||||
name="Current bill gas usage to date",
|
||||
translation_key="gas_usage_to_date",
|
||||
device_class=SensorDeviceClass.GAS,
|
||||
native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
@@ -120,7 +122,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
|
||||
),
|
||||
OpowerEntityDescription(
|
||||
key="gas_forecasted_usage",
|
||||
name="Current bill gas forecasted usage",
|
||||
translation_key="gas_forecasted_usage",
|
||||
device_class=SensorDeviceClass.GAS,
|
||||
native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
@@ -129,7 +131,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
|
||||
),
|
||||
OpowerEntityDescription(
|
||||
key="gas_typical_usage",
|
||||
name="Typical monthly gas usage",
|
||||
translation_key="gas_typical_usage",
|
||||
device_class=SensorDeviceClass.GAS,
|
||||
native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
@@ -138,7 +140,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
|
||||
),
|
||||
OpowerEntityDescription(
|
||||
key="gas_cost_to_date",
|
||||
name="Current bill gas cost to date",
|
||||
translation_key="gas_cost_to_date",
|
||||
device_class=SensorDeviceClass.MONETARY,
|
||||
native_unit_of_measurement="USD",
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
@@ -147,7 +149,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
|
||||
),
|
||||
OpowerEntityDescription(
|
||||
key="gas_forecasted_cost",
|
||||
name="Current bill gas forecasted cost",
|
||||
translation_key="gas_forecasted_cost",
|
||||
device_class=SensorDeviceClass.MONETARY,
|
||||
native_unit_of_measurement="USD",
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
@@ -156,7 +158,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
|
||||
),
|
||||
OpowerEntityDescription(
|
||||
key="gas_typical_cost",
|
||||
name="Typical monthly gas cost",
|
||||
translation_key="gas_typical_cost",
|
||||
device_class=SensorDeviceClass.MONETARY,
|
||||
native_unit_of_measurement="USD",
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
@@ -165,7 +167,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
|
||||
),
|
||||
OpowerEntityDescription(
|
||||
key="gas_start_date",
|
||||
name="Current bill gas start date",
|
||||
translation_key="gas_start_date",
|
||||
device_class=SensorDeviceClass.DATE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
@@ -173,7 +175,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
|
||||
),
|
||||
OpowerEntityDescription(
|
||||
key="gas_end_date",
|
||||
name="Current bill gas end date",
|
||||
translation_key="gas_end_date",
|
||||
device_class=SensorDeviceClass.DATE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
@@ -229,6 +231,7 @@ async def async_setup_entry(
|
||||
class OpowerSensor(CoordinatorEntity[OpowerCoordinator], SensorEntity):
|
||||
"""Representation of an Opower sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
entity_description: OpowerEntityDescription
|
||||
|
||||
def __init__(
|
||||
@@ -249,8 +252,6 @@ class OpowerSensor(CoordinatorEntity[OpowerCoordinator], SensorEntity):
|
||||
@property
|
||||
def native_value(self) -> StateType | date:
|
||||
"""Return the state."""
|
||||
if self.coordinator.data is not None:
|
||||
return self.entity_description.value_fn(
|
||||
self.coordinator.data[self.utility_account_id]
|
||||
)
|
||||
return None
|
||||
return self.entity_description.value_fn(
|
||||
self.coordinator.data[self.utility_account_id]
|
||||
)
|
||||
|
@@ -37,5 +37,57 @@
|
||||
"title": "Return to grid statistics for account: {utility_account_id}",
|
||||
"description": "We found negative values in your existing consumption statistics, likely because you have solar. We split those in separate return statistics for a better experience in the Energy dashboard.\n\nPlease visit the [Energy configuration page]({energy_settings}) to add the following statistics in the **Return to grid** section:\n\n{target_ids}\n\nOnce you have added them, ignore this issue."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"elec_usage_to_date": {
|
||||
"name": "Current bill electric usage to date"
|
||||
},
|
||||
"elec_forecasted_usage": {
|
||||
"name": "Current bill electric forecasted usage"
|
||||
},
|
||||
"elec_typical_usage": {
|
||||
"name": "Typical monthly electric usage"
|
||||
},
|
||||
"elec_cost_to_date": {
|
||||
"name": "Current bill electric cost to date"
|
||||
},
|
||||
"elec_forecasted_cost": {
|
||||
"name": "Current bill electric forecasted cost"
|
||||
},
|
||||
"elec_typical_cost": {
|
||||
"name": "Typical monthly electric cost"
|
||||
},
|
||||
"elec_start_date": {
|
||||
"name": "Current bill electric start date"
|
||||
},
|
||||
"elec_end_date": {
|
||||
"name": "Current bill electric end date"
|
||||
},
|
||||
"gas_usage_to_date": {
|
||||
"name": "Current bill gas usage to date"
|
||||
},
|
||||
"gas_forecasted_usage": {
|
||||
"name": "Current bill gas forecasted usage"
|
||||
},
|
||||
"gas_typical_usage": {
|
||||
"name": "Typical monthly gas usage"
|
||||
},
|
||||
"gas_cost_to_date": {
|
||||
"name": "Current bill gas cost to date"
|
||||
},
|
||||
"gas_forecasted_cost": {
|
||||
"name": "Current bill gas forecasted cost"
|
||||
},
|
||||
"gas_typical_cost": {
|
||||
"name": "Typical monthly gas cost"
|
||||
},
|
||||
"gas_start_date": {
|
||||
"name": "Current bill gas start date"
|
||||
},
|
||||
"gas_end_date": {
|
||||
"name": "Current bill gas end date"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
12
homeassistant/components/remote_calendar/client.py
Normal file
12
homeassistant/components/remote_calendar/client.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""Specifies the parameter for the httpx download."""
|
||||
|
||||
from httpx import AsyncClient, Response, Timeout
|
||||
|
||||
|
||||
async def get_calendar(client: AsyncClient, url: str) -> Response:
|
||||
"""Make an HTTP GET request using Home Assistant's async HTTPX client with timeout."""
|
||||
return await client.get(
|
||||
url,
|
||||
follow_redirects=True,
|
||||
timeout=Timeout(5, read=30, write=5, pool=5),
|
||||
)
|
@@ -4,13 +4,14 @@ from http import HTTPStatus
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from httpx import HTTPError, InvalidURL
|
||||
from httpx import HTTPError, InvalidURL, TimeoutException
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_URL
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
|
||||
from .client import get_calendar
|
||||
from .const import CONF_CALENDAR_NAME, DOMAIN
|
||||
from .ics import InvalidIcsException, parse_calendar
|
||||
|
||||
@@ -49,7 +50,7 @@ class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]})
|
||||
client = get_async_client(self.hass)
|
||||
try:
|
||||
res = await client.get(user_input[CONF_URL], follow_redirects=True)
|
||||
res = await get_calendar(client, user_input[CONF_URL])
|
||||
if res.status_code == HTTPStatus.FORBIDDEN:
|
||||
errors["base"] = "forbidden"
|
||||
return self.async_show_form(
|
||||
@@ -58,9 +59,14 @@ class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
res.raise_for_status()
|
||||
except TimeoutException as err:
|
||||
errors["base"] = "timeout_connect"
|
||||
_LOGGER.debug(
|
||||
"A timeout error occurred: %s", str(err) or type(err).__name__
|
||||
)
|
||||
except (HTTPError, InvalidURL) as err:
|
||||
errors["base"] = "cannot_connect"
|
||||
_LOGGER.debug("An error occurred: %s", err)
|
||||
_LOGGER.debug("An error occurred: %s", str(err) or type(err).__name__)
|
||||
else:
|
||||
try:
|
||||
await parse_calendar(self.hass, res.text)
|
||||
|
@@ -3,7 +3,7 @@
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from httpx import HTTPError, InvalidURL
|
||||
from httpx import HTTPError, InvalidURL, TimeoutException
|
||||
from ical.calendar import Calendar
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -12,6 +12,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .client import get_calendar
|
||||
from .const import DOMAIN
|
||||
from .ics import InvalidIcsException, parse_calendar
|
||||
|
||||
@@ -36,7 +37,7 @@ class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]):
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=DOMAIN,
|
||||
name=f"{DOMAIN}_{config_entry.title}",
|
||||
update_interval=SCAN_INTERVAL,
|
||||
always_update=True,
|
||||
)
|
||||
@@ -46,13 +47,19 @@ class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]):
|
||||
async def _async_update_data(self) -> Calendar:
|
||||
"""Update data from the url."""
|
||||
try:
|
||||
res = await self._client.get(self._url, follow_redirects=True)
|
||||
res = await get_calendar(self._client, self._url)
|
||||
res.raise_for_status()
|
||||
except TimeoutException as err:
|
||||
_LOGGER.debug("%s: %s", self._url, str(err) or type(err).__name__)
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="timeout",
|
||||
) from err
|
||||
except (HTTPError, InvalidURL) as err:
|
||||
_LOGGER.debug("%s: %s", self._url, str(err) or type(err).__name__)
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unable_to_fetch",
|
||||
translation_placeholders={"err": str(err)},
|
||||
) from err
|
||||
try:
|
||||
self.ics = res.text
|
||||
|
@@ -18,14 +18,18 @@
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
|
||||
},
|
||||
"error": {
|
||||
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"forbidden": "The server understood the request but refuses to authorize it.",
|
||||
"invalid_ics_file": "There was a problem reading the calendar information. See the error log for additional details."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"timeout": {
|
||||
"message": "The connection timed out. See the debug log for additional details."
|
||||
},
|
||||
"unable_to_fetch": {
|
||||
"message": "Unable to fetch calendar data: {err}"
|
||||
"message": "Unable to fetch calendar data. See the debug log for additional details."
|
||||
},
|
||||
"unable_to_parse": {
|
||||
"message": "Unable to parse calendar data: {err}"
|
||||
|
@@ -7,6 +7,6 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiorussound"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aiorussound==4.6.1"],
|
||||
"requirements": ["aiorussound==4.7.0"],
|
||||
"zeroconf": ["_rio._tcp.local."]
|
||||
}
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/weatherflow_cloud",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["weatherflow4py"],
|
||||
"requirements": ["weatherflow4py==1.3.1"]
|
||||
"requirements": ["weatherflow4py==1.4.1"]
|
||||
}
|
||||
|
@@ -185,6 +185,16 @@ def report_usage(
|
||||
"""
|
||||
if (hass := _hass.hass) is None:
|
||||
raise RuntimeError("Frame helper not set up")
|
||||
integration_frame: IntegrationFrame | None = None
|
||||
integration_frame_err: MissingIntegrationFrame | None = None
|
||||
if not integration_domain:
|
||||
try:
|
||||
integration_frame = get_integration_frame(
|
||||
exclude_integrations=exclude_integrations
|
||||
)
|
||||
except MissingIntegrationFrame as err:
|
||||
if core_behavior is ReportBehavior.ERROR:
|
||||
integration_frame_err = err
|
||||
_report_usage_partial = functools.partial(
|
||||
_report_usage,
|
||||
hass,
|
||||
@@ -193,8 +203,9 @@ def report_usage(
|
||||
core_behavior=core_behavior,
|
||||
core_integration_behavior=core_integration_behavior,
|
||||
custom_integration_behavior=custom_integration_behavior,
|
||||
exclude_integrations=exclude_integrations,
|
||||
integration_domain=integration_domain,
|
||||
integration_frame=integration_frame,
|
||||
integration_frame_err=integration_frame_err,
|
||||
level=level,
|
||||
)
|
||||
if hass.loop_thread_id != threading.get_ident():
|
||||
@@ -212,8 +223,9 @@ def _report_usage(
|
||||
core_behavior: ReportBehavior,
|
||||
core_integration_behavior: ReportBehavior,
|
||||
custom_integration_behavior: ReportBehavior,
|
||||
exclude_integrations: set[str] | None,
|
||||
integration_domain: str | None,
|
||||
integration_frame: IntegrationFrame | None,
|
||||
integration_frame_err: MissingIntegrationFrame | None,
|
||||
level: int,
|
||||
) -> None:
|
||||
"""Report incorrect code usage.
|
||||
@@ -235,12 +247,10 @@ def _report_usage(
|
||||
_report_usage_no_integration(what, core_behavior, breaks_in_ha_version, None)
|
||||
return
|
||||
|
||||
try:
|
||||
integration_frame = get_integration_frame(
|
||||
exclude_integrations=exclude_integrations
|
||||
if not integration_frame:
|
||||
_report_usage_no_integration(
|
||||
what, core_behavior, breaks_in_ha_version, integration_frame_err
|
||||
)
|
||||
except MissingIntegrationFrame as err:
|
||||
_report_usage_no_integration(what, core_behavior, breaks_in_ha_version, err)
|
||||
return
|
||||
|
||||
integration_behavior = core_integration_behavior
|
||||
|
@@ -777,7 +777,23 @@ def _selector_serializer(schema: Any) -> Any: # noqa: C901
|
||||
return result
|
||||
|
||||
if isinstance(schema, selector.ObjectSelector):
|
||||
return {"type": "object", "additionalProperties": True}
|
||||
result = {"type": "object"}
|
||||
if fields := schema.config.get("fields"):
|
||||
result["properties"] = {
|
||||
field: convert(
|
||||
selector.selector(field_schema["selector"]),
|
||||
custom_serializer=_selector_serializer,
|
||||
)
|
||||
for field, field_schema in fields.items()
|
||||
}
|
||||
else:
|
||||
result["additionalProperties"] = True
|
||||
if schema.config.get("multiple"):
|
||||
result = {
|
||||
"type": "array",
|
||||
"items": result,
|
||||
}
|
||||
return result
|
||||
|
||||
if isinstance(schema, selector.SelectSelector):
|
||||
options = [
|
||||
|
@@ -86,6 +86,7 @@ ALL_SERVICE_DESCRIPTIONS_CACHE: HassKey[
|
||||
def _base_components() -> dict[str, ModuleType]:
|
||||
"""Return a cached lookup of base components."""
|
||||
from homeassistant.components import ( # noqa: PLC0415
|
||||
ai_task,
|
||||
alarm_control_panel,
|
||||
assist_satellite,
|
||||
calendar,
|
||||
@@ -107,6 +108,7 @@ def _base_components() -> dict[str, ModuleType]:
|
||||
)
|
||||
|
||||
return {
|
||||
"ai_task": ai_task,
|
||||
"alarm_control_panel": alarm_control_panel,
|
||||
"assist_satellite": assist_satellite,
|
||||
"calendar": calendar,
|
||||
|
@@ -938,15 +938,17 @@ class AllStates:
|
||||
def __call__(
|
||||
self,
|
||||
entity_id: str,
|
||||
rounded: bool = True,
|
||||
rounded: bool | object = _SENTINEL,
|
||||
with_unit: bool = False,
|
||||
) -> str:
|
||||
"""Return the states."""
|
||||
state = _get_state(self._hass, entity_id)
|
||||
if state is None:
|
||||
return STATE_UNKNOWN
|
||||
if rounded is _SENTINEL:
|
||||
rounded = with_unit
|
||||
if rounded or with_unit:
|
||||
return state.format_state(rounded, with_unit)
|
||||
return state.format_state(rounded, with_unit) # type: ignore[arg-type]
|
||||
return state.state
|
||||
|
||||
def __repr__(self) -> str:
|
||||
|
@@ -147,11 +147,15 @@ async def _register_trigger_platform(
|
||||
)
|
||||
return
|
||||
|
||||
tasks: list[asyncio.Task[None]] = [
|
||||
create_eager_task(listener(new_triggers))
|
||||
for listener in hass.data[TRIGGER_PLATFORM_SUBSCRIPTIONS]
|
||||
]
|
||||
await asyncio.gather(*tasks)
|
||||
# We don't use gather here because gather adds additional overhead
|
||||
# when wrapping each coroutine in a task, and we expect our listeners
|
||||
# to call trigger.async_get_all_descriptions which will only yield
|
||||
# the first time it's called, after that it returns cached data.
|
||||
for listener in hass.data[TRIGGER_PLATFORM_SUBSCRIPTIONS]:
|
||||
try:
|
||||
await listener(new_triggers)
|
||||
except Exception:
|
||||
_LOGGER.exception("Error while notifying trigger platform listener")
|
||||
|
||||
|
||||
class Trigger(abc.ABC):
|
||||
|
@@ -1789,6 +1789,13 @@ def async_get_issue_tracker(
|
||||
# If we know nothing about the integration, suggest opening an issue on HA core
|
||||
return issue_tracker
|
||||
|
||||
if module and not integration_domain:
|
||||
# If we only have a module, we can try to get the integration domain from it
|
||||
if module.startswith("custom_components."):
|
||||
integration_domain = module.split(".")[1]
|
||||
elif module.startswith("homeassistant.components."):
|
||||
integration_domain = module.split(".")[2]
|
||||
|
||||
if not integration:
|
||||
integration = async_get_issue_integration(hass, integration_domain)
|
||||
|
||||
|
8
requirements_all.txt
generated
8
requirements_all.txt
generated
@@ -372,7 +372,7 @@ aioridwell==2024.01.0
|
||||
aioruckus==0.42
|
||||
|
||||
# homeassistant.components.russound_rio
|
||||
aiorussound==4.6.1
|
||||
aiorussound==4.7.0
|
||||
|
||||
# homeassistant.components.ruuvi_gateway
|
||||
aioruuvigateway==0.1.0
|
||||
@@ -477,7 +477,7 @@ amcrest==1.9.8
|
||||
androidtv[async]==0.0.75
|
||||
|
||||
# homeassistant.components.androidtv_remote
|
||||
androidtvremote2==0.2.2
|
||||
androidtvremote2==0.2.3
|
||||
|
||||
# homeassistant.components.anel_pwrctrl
|
||||
anel-pwrctrl-homeassistant==0.0.1.dev2
|
||||
@@ -1962,7 +1962,7 @@ pyeiscp==0.0.7
|
||||
pyemoncms==0.1.1
|
||||
|
||||
# homeassistant.components.enphase_envoy
|
||||
pyenphase==2.1.0
|
||||
pyenphase==2.2.0
|
||||
|
||||
# homeassistant.components.envisalink
|
||||
pyenvisalink==4.7
|
||||
@@ -3084,7 +3084,7 @@ waterfurnace==1.1.0
|
||||
watergate-local-api==2024.4.1
|
||||
|
||||
# homeassistant.components.weatherflow_cloud
|
||||
weatherflow4py==1.3.1
|
||||
weatherflow4py==1.4.1
|
||||
|
||||
# homeassistant.components.cisco_webex_teams
|
||||
webexpythonsdk==2.0.1
|
||||
|
@@ -11,7 +11,7 @@ astroid==3.3.10
|
||||
coverage==7.9.1
|
||||
freezegun==1.5.2
|
||||
go2rtc-client==0.2.1
|
||||
license-expression==30.4.1
|
||||
license-expression==30.4.3
|
||||
mock-open==1.4.0
|
||||
mypy-dev==1.17.0a4
|
||||
pre-commit==4.2.0
|
||||
|
8
requirements_test_all.txt
generated
8
requirements_test_all.txt
generated
@@ -354,7 +354,7 @@ aioridwell==2024.01.0
|
||||
aioruckus==0.42
|
||||
|
||||
# homeassistant.components.russound_rio
|
||||
aiorussound==4.6.1
|
||||
aiorussound==4.7.0
|
||||
|
||||
# homeassistant.components.ruuvi_gateway
|
||||
aioruuvigateway==0.1.0
|
||||
@@ -453,7 +453,7 @@ amberelectric==2.0.12
|
||||
androidtv[async]==0.0.75
|
||||
|
||||
# homeassistant.components.androidtv_remote
|
||||
androidtvremote2==0.2.2
|
||||
androidtvremote2==0.2.3
|
||||
|
||||
# homeassistant.components.anova
|
||||
anova-wifi==0.17.0
|
||||
@@ -1637,7 +1637,7 @@ pyeiscp==0.0.7
|
||||
pyemoncms==0.1.1
|
||||
|
||||
# homeassistant.components.enphase_envoy
|
||||
pyenphase==2.1.0
|
||||
pyenphase==2.2.0
|
||||
|
||||
# homeassistant.components.everlights
|
||||
pyeverlights==0.1.0
|
||||
@@ -2543,7 +2543,7 @@ watchdog==6.0.0
|
||||
watergate-local-api==2024.4.1
|
||||
|
||||
# homeassistant.components.weatherflow_cloud
|
||||
weatherflow4py==1.3.1
|
||||
weatherflow4py==1.4.1
|
||||
|
||||
# homeassistant.components.nasweb
|
||||
webio-api==0.1.11
|
||||
|
@@ -205,11 +205,17 @@ EXCEPTIONS = {
|
||||
"tapsaff", # https://github.com/bazwilliams/python-taps-aff/pull/5
|
||||
}
|
||||
|
||||
# fmt: off
|
||||
TODO = {
|
||||
"TravisPy": AwesomeVersion("0.3.5"), # None -- GPL -- ['GNU General Public License v3 (GPLv3)']
|
||||
"aiocache": AwesomeVersion(
|
||||
"0.12.3"
|
||||
), # https://github.com/aio-libs/aiocache/blob/master/LICENSE all rights reserved?
|
||||
"caldav": AwesomeVersion("1.6.0"), # None -- GPL -- ['GNU General Public License (GPL)', 'Apache Software License'] # https://github.com/python-caldav/caldav
|
||||
"pyiskra": AwesomeVersion("0.1.21"), # None -- GPL -- ['GNU General Public License v3 (GPLv3)']
|
||||
"xbox-webapi": AwesomeVersion("2.1.0"), # None -- GPL -- ['MIT License']
|
||||
}
|
||||
# fmt: on
|
||||
|
||||
EXCEPTIONS_AND_TODOS = EXCEPTIONS.union(TODO)
|
||||
|
||||
|
@@ -1,5 +1,7 @@
|
||||
"""Test helpers for AI Task integration."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.ai_task import (
|
||||
@@ -45,12 +47,18 @@ class MockAITaskEntity(AITaskEntity):
|
||||
) -> GenDataTaskResult:
|
||||
"""Mock handling of generate data task."""
|
||||
self.mock_generate_data_tasks.append(task)
|
||||
if task.structure is not None:
|
||||
data = {"name": "Tracy Chen", "age": 30}
|
||||
data_chat_log = json.dumps(data)
|
||||
else:
|
||||
data = "Mock result"
|
||||
data_chat_log = data
|
||||
chat_log.async_add_assistant_content_without_tools(
|
||||
AssistantContent(self.entity_id, "Mock result")
|
||||
AssistantContent(self.entity_id, data_chat_log)
|
||||
)
|
||||
return GenDataTaskResult(
|
||||
conversation_id=chat_log.conversation_id,
|
||||
data="Mock result",
|
||||
data=data,
|
||||
)
|
||||
|
||||
|
||||
|
@@ -1,10 +1,12 @@
|
||||
"""Tests for the AI Task entity model."""
|
||||
|
||||
from freezegun import freeze_time
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.ai_task import async_generate_data
|
||||
from homeassistant.const import STATE_UNKNOWN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import selector
|
||||
|
||||
from .conftest import TEST_ENTITY_ID, MockAITaskEntity
|
||||
|
||||
@@ -37,3 +39,40 @@ async def test_state_generate_data(
|
||||
assert mock_ai_task_entity.mock_generate_data_tasks
|
||||
task = mock_ai_task_entity.mock_generate_data_tasks[0]
|
||||
assert task.instructions == "Test prompt"
|
||||
|
||||
|
||||
async def test_generate_structured_data(
|
||||
hass: HomeAssistant,
|
||||
init_components: None,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_ai_task_entity: MockAITaskEntity,
|
||||
) -> None:
|
||||
"""Test the entity can generate structured data."""
|
||||
result = await async_generate_data(
|
||||
hass,
|
||||
task_name="Test task",
|
||||
entity_id=TEST_ENTITY_ID,
|
||||
instructions="Please generate a profile for a new user",
|
||||
structure=vol.Schema(
|
||||
{
|
||||
vol.Required("name"): selector.TextSelector(),
|
||||
vol.Optional("age"): selector.NumberSelector(
|
||||
config=selector.NumberSelectorConfig(
|
||||
min=0,
|
||||
max=120,
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
# Arbitrary data returned by the mock entity (not determined by above schema in test)
|
||||
assert result.data == {
|
||||
"name": "Tracy Chen",
|
||||
"age": 30,
|
||||
}
|
||||
|
||||
assert mock_ai_task_entity.mock_generate_data_tasks
|
||||
task = mock_ai_task_entity.mock_generate_data_tasks[0]
|
||||
assert task.instructions == "Please generate a profile for a new user"
|
||||
assert task.structure
|
||||
assert isinstance(task.structure, vol.Schema)
|
||||
|
@@ -1,13 +1,17 @@
|
||||
"""Test initialization of the AI Task component."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.ai_task import AITaskPreferences
|
||||
from homeassistant.components.ai_task.const import DATA_PREFERENCES
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import selector
|
||||
|
||||
from .conftest import TEST_ENTITY_ID
|
||||
from .conftest import TEST_ENTITY_ID, MockAITaskEntity
|
||||
|
||||
from tests.common import flush_store
|
||||
|
||||
@@ -82,3 +86,160 @@ async def test_generate_data_service(
|
||||
)
|
||||
|
||||
assert result["data"] == "Mock result"
|
||||
|
||||
|
||||
async def test_generate_data_service_structure_fields(
|
||||
hass: HomeAssistant,
|
||||
init_components: None,
|
||||
mock_ai_task_entity: MockAITaskEntity,
|
||||
) -> None:
|
||||
"""Test the entity can generate structured data with a top level object schemea."""
|
||||
result = await hass.services.async_call(
|
||||
"ai_task",
|
||||
"generate_data",
|
||||
{
|
||||
"task_name": "Profile Generation",
|
||||
"instructions": "Please generate a profile for a new user",
|
||||
"entity_id": TEST_ENTITY_ID,
|
||||
"structure": {
|
||||
"name": {
|
||||
"description": "First and last name of the user such as Alice Smith",
|
||||
"required": True,
|
||||
"selector": {"text": {}},
|
||||
},
|
||||
"age": {
|
||||
"description": "Age of the user",
|
||||
"selector": {
|
||||
"number": {
|
||||
"min": 0,
|
||||
"max": 120,
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
# Arbitrary data returned by the mock entity (not determined by above schema in test)
|
||||
assert result["data"] == {
|
||||
"name": "Tracy Chen",
|
||||
"age": 30,
|
||||
}
|
||||
|
||||
assert mock_ai_task_entity.mock_generate_data_tasks
|
||||
task = mock_ai_task_entity.mock_generate_data_tasks[0]
|
||||
assert task.instructions == "Please generate a profile for a new user"
|
||||
assert task.structure
|
||||
assert isinstance(task.structure, vol.Schema)
|
||||
schema = list(task.structure.schema.items())
|
||||
assert len(schema) == 2
|
||||
|
||||
name_key, name_value = schema[0]
|
||||
assert name_key == "name"
|
||||
assert isinstance(name_key, vol.Required)
|
||||
assert name_key.description == "First and last name of the user such as Alice Smith"
|
||||
assert isinstance(name_value, selector.TextSelector)
|
||||
|
||||
age_key, age_value = schema[1]
|
||||
assert age_key == "age"
|
||||
assert isinstance(age_key, vol.Optional)
|
||||
assert age_key.description == "Age of the user"
|
||||
assert isinstance(age_value, selector.NumberSelector)
|
||||
assert age_value.config["min"] == 0
|
||||
assert age_value.config["max"] == 120
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("structure", "expected_exception", "expected_error"),
|
||||
[
|
||||
(
|
||||
{
|
||||
"name": {
|
||||
"description": "First and last name of the user such as Alice Smith",
|
||||
"selector": {"invalid-selector": {}},
|
||||
},
|
||||
},
|
||||
vol.Invalid,
|
||||
r"Unknown selector type invalid-selector.*",
|
||||
),
|
||||
(
|
||||
{
|
||||
"name": {
|
||||
"description": "First and last name of the user such as Alice Smith",
|
||||
"selector": {
|
||||
"text": {
|
||||
"extra-config": False,
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
vol.Invalid,
|
||||
r"extra keys not allowed.*",
|
||||
),
|
||||
(
|
||||
{
|
||||
"name": {
|
||||
"description": "First and last name of the user such as Alice Smith",
|
||||
},
|
||||
},
|
||||
vol.Invalid,
|
||||
r"required key not provided.*selector.*",
|
||||
),
|
||||
(12345, vol.Invalid, r"xpected a dictionary.*"),
|
||||
("name", vol.Invalid, r"xpected a dictionary.*"),
|
||||
(["name"], vol.Invalid, r"xpected a dictionary.*"),
|
||||
(
|
||||
{
|
||||
"name": {
|
||||
"description": "First and last name of the user such as Alice Smith",
|
||||
"selector": {"text": {}},
|
||||
"extra-fields": "Some extra fields",
|
||||
},
|
||||
},
|
||||
vol.Invalid,
|
||||
r"extra keys not allowed .*",
|
||||
),
|
||||
(
|
||||
{
|
||||
"name": {
|
||||
"description": "First and last name of the user such as Alice Smith",
|
||||
"selector": "invalid-schema",
|
||||
},
|
||||
},
|
||||
vol.Invalid,
|
||||
r"xpected a dictionary for dictionary.",
|
||||
),
|
||||
],
|
||||
ids=(
|
||||
"invalid-selector",
|
||||
"invalid-selector-config",
|
||||
"missing-selector",
|
||||
"structure-is-int-not-object",
|
||||
"structure-is-str-not-object",
|
||||
"structure-is-list-not-object",
|
||||
"extra-fields",
|
||||
"invalid-selector-schema",
|
||||
),
|
||||
)
|
||||
async def test_generate_data_service_invalid_structure(
|
||||
hass: HomeAssistant,
|
||||
init_components: None,
|
||||
structure: Any,
|
||||
expected_exception: Exception,
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
"""Test the entity can generate structured data."""
|
||||
with pytest.raises(expected_exception, match=expected_error):
|
||||
await hass.services.async_call(
|
||||
"ai_task",
|
||||
"generate_data",
|
||||
{
|
||||
"task_name": "Profile Generation",
|
||||
"instructions": "Please generate a profile for a new user",
|
||||
"entity_id": TEST_ENTITY_ID,
|
||||
"structure": structure,
|
||||
},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
@@ -1069,3 +1069,100 @@ async def test_options_flow(
|
||||
)
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert mock_config_entry.options == {CONF_ENABLE_IME: True}
|
||||
|
||||
|
||||
async def test_reconfigure_flow_success(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_api: MagicMock,
|
||||
) -> None:
|
||||
"""Test the full reconfigure flow from start to finish without any exceptions."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await mock_config_entry.start_reconfigure_flow(hass)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reconfigure"
|
||||
assert not result["errors"]
|
||||
assert "host" in result["data_schema"].schema
|
||||
# Form should have as default value the existing host
|
||||
host_key = next(k for k in result["data_schema"].schema if k.schema == "host")
|
||||
assert host_key.default() == mock_config_entry.data["host"]
|
||||
|
||||
mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True)
|
||||
mock_api.async_get_name_and_mac = AsyncMock(
|
||||
return_value=(mock_config_entry.data["name"], mock_config_entry.data["mac"])
|
||||
)
|
||||
|
||||
# Simulate user input with a new host
|
||||
new_host = "4.3.2.1"
|
||||
assert new_host != mock_config_entry.data["host"]
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"host": new_host}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reconfigure_successful"
|
||||
assert mock_config_entry.data["host"] == new_host
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_reconfigure_flow_cannot_connect(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_api: MagicMock,
|
||||
) -> None:
|
||||
"""Test reconfigure flow with CannotConnect exception."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await mock_config_entry.start_reconfigure_flow(hass)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reconfigure"
|
||||
|
||||
mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True)
|
||||
mock_api.async_get_name_and_mac = AsyncMock(side_effect=CannotConnect())
|
||||
|
||||
new_host = "4.3.2.1"
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"host": new_host}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reconfigure"
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
assert mock_config_entry.data["host"] == "1.2.3.4"
|
||||
assert len(mock_setup_entry.mock_calls) == 0
|
||||
|
||||
|
||||
async def test_reconfigure_flow_unique_id_mismatch(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_api: MagicMock,
|
||||
) -> None:
|
||||
"""Test reconfigure flow with a different device (unique_id mismatch)."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await mock_config_entry.start_reconfigure_flow(hass)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reconfigure"
|
||||
|
||||
mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True)
|
||||
# The new host corresponds to a device with a different MAC/unique_id
|
||||
new_mac = "FF:EE:DD:CC:BB:AA"
|
||||
assert new_mac != mock_config_entry.data["mac"]
|
||||
mock_api.async_get_name_and_mac = AsyncMock(return_value=("name", new_mac))
|
||||
|
||||
new_host = "4.3.2.1"
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"host": new_host}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "unique_id_mismatch"
|
||||
assert mock_config_entry.data["host"] == "1.2.3.4"
|
||||
assert len(mock_setup_entry.mock_calls) == 0
|
||||
|
@@ -793,12 +793,19 @@ async def test_start_conversation_default_preannounce(
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("service_data", "response_text", "expected_answer"),
|
||||
("service_data", "response_text", "expected_answer", "should_preannounce"),
|
||||
[
|
||||
(
|
||||
{},
|
||||
"jazz",
|
||||
AssistSatelliteAnswer(id=None, sentence="jazz"),
|
||||
True,
|
||||
),
|
||||
(
|
||||
{"preannounce": False},
|
||||
"jazz",
|
||||
AssistSatelliteAnswer(id=None, sentence="jazz"),
|
||||
False,
|
||||
),
|
||||
(
|
||||
{
|
||||
@@ -810,6 +817,7 @@ async def test_start_conversation_default_preannounce(
|
||||
},
|
||||
"Some Rock, please.",
|
||||
AssistSatelliteAnswer(id="rock", sentence="Some Rock, please."),
|
||||
False,
|
||||
),
|
||||
(
|
||||
{
|
||||
@@ -827,7 +835,7 @@ async def test_start_conversation_default_preannounce(
|
||||
"sentences": ["artist {artist} [please]"],
|
||||
},
|
||||
],
|
||||
"preannounce": False,
|
||||
"preannounce": True,
|
||||
},
|
||||
"artist Pink Floyd",
|
||||
AssistSatelliteAnswer(
|
||||
@@ -835,6 +843,7 @@ async def test_start_conversation_default_preannounce(
|
||||
sentence="artist Pink Floyd",
|
||||
slots={"artist": "Pink Floyd"},
|
||||
),
|
||||
True,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -845,6 +854,7 @@ async def test_ask_question(
|
||||
service_data: dict,
|
||||
response_text: str,
|
||||
expected_answer: AssistSatelliteAnswer,
|
||||
should_preannounce: bool,
|
||||
) -> None:
|
||||
"""Test asking a question on a device and matching an answer."""
|
||||
entity_id = "assist_satellite.test_entity"
|
||||
@@ -868,6 +878,9 @@ async def test_ask_question(
|
||||
async def async_start_conversation(start_announcement):
|
||||
# Verify state change
|
||||
assert entity.state == AssistSatelliteState.RESPONDING
|
||||
assert (
|
||||
start_announcement.preannounce_media_id is not None
|
||||
) is should_preannounce
|
||||
await original_start_conversation(start_announcement)
|
||||
|
||||
audio_stream = object()
|
||||
|
@@ -129,6 +129,7 @@ async def test_async_step_reauth(
|
||||
CONF_PASSWORD: "test-password",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
@@ -639,6 +640,7 @@ async def test_reauth_errors(
|
||||
CONF_PASSWORD: "test-password",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
|
@@ -7,6 +7,7 @@ import pytest
|
||||
|
||||
from homeassistant.components.google_generative_ai_conversation.const import (
|
||||
CONF_USE_GOOGLE_SEARCH_TOOL,
|
||||
DEFAULT_AI_TASK_NAME,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
DEFAULT_TTS_NAME,
|
||||
)
|
||||
@@ -29,6 +30,7 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
|
||||
"api_key": "bla",
|
||||
},
|
||||
version=2,
|
||||
minor_version=3,
|
||||
subentries_data=[
|
||||
{
|
||||
"data": {},
|
||||
@@ -44,6 +46,13 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
|
||||
"subentry_id": "ulid-tts",
|
||||
"unique_id": None,
|
||||
},
|
||||
{
|
||||
"data": {},
|
||||
"subentry_type": "ai_task_data",
|
||||
"title": DEFAULT_AI_TASK_NAME,
|
||||
"subentry_id": "ulid-ai-task",
|
||||
"unique_id": None,
|
||||
},
|
||||
],
|
||||
)
|
||||
entry.runtime_data = Mock()
|
||||
|
@@ -7,6 +7,14 @@
|
||||
'options': dict({
|
||||
}),
|
||||
'subentries': dict({
|
||||
'ulid-ai-task': dict({
|
||||
'data': dict({
|
||||
}),
|
||||
'subentry_id': 'ulid-ai-task',
|
||||
'subentry_type': 'ai_task_data',
|
||||
'title': 'Google AI Task',
|
||||
'unique_id': None,
|
||||
}),
|
||||
'ulid-conversation': dict({
|
||||
'data': dict({
|
||||
'chat_model': 'models/gemini-2.5-flash',
|
||||
|
@@ -32,6 +32,37 @@
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
}),
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': <DeviceEntryType.SERVICE: 'service'>,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'google_generative_ai_conversation',
|
||||
'ulid-ai-task',
|
||||
),
|
||||
}),
|
||||
'is_new': False,
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Google',
|
||||
'model': 'gemini-2.5-flash',
|
||||
'model_id': None,
|
||||
'name': 'Google AI Task',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'suggested_area': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
}),
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
|
@@ -0,0 +1,62 @@
|
||||
"""Test AI Task platform of Google Generative AI Conversation integration."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from google.genai.types import GenerateContentResponse
|
||||
import pytest
|
||||
|
||||
from homeassistant.components import ai_task
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.components.conversation import (
|
||||
MockChatLog,
|
||||
mock_chat_log, # noqa: F401
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_init_component")
|
||||
async def test_run_task(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_chat_log: MockChatLog, # noqa: F811
|
||||
mock_send_message_stream: AsyncMock,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test empty response."""
|
||||
entity_id = "ai_task.google_ai_task"
|
||||
|
||||
# Ensure it's linked to the subentry
|
||||
entity_entry = entity_registry.async_get(entity_id)
|
||||
ai_task_entry = next(
|
||||
iter(
|
||||
entry
|
||||
for entry in mock_config_entry.subentries.values()
|
||||
if entry.subentry_type == "ai_task_data"
|
||||
)
|
||||
)
|
||||
assert entity_entry.config_entry_id == mock_config_entry.entry_id
|
||||
assert entity_entry.config_subentry_id == ai_task_entry.subentry_id
|
||||
|
||||
mock_send_message_stream.return_value = [
|
||||
[
|
||||
GenerateContentResponse(
|
||||
candidates=[
|
||||
{
|
||||
"content": {
|
||||
"parts": [{"text": "Hi there!"}],
|
||||
"role": "model",
|
||||
},
|
||||
}
|
||||
],
|
||||
),
|
||||
],
|
||||
]
|
||||
result = await ai_task.async_generate_data(
|
||||
hass,
|
||||
task_name="Test Task",
|
||||
entity_id=entity_id,
|
||||
instructions="Test prompt",
|
||||
)
|
||||
assert result.data == "Hi there!"
|
@@ -19,9 +19,11 @@ from homeassistant.components.google_generative_ai_conversation.const import (
|
||||
CONF_TOP_K,
|
||||
CONF_TOP_P,
|
||||
CONF_USE_GOOGLE_SEARCH_TOOL,
|
||||
DEFAULT_AI_TASK_NAME,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
DEFAULT_TTS_NAME,
|
||||
DOMAIN,
|
||||
RECOMMENDED_AI_TASK_OPTIONS,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
RECOMMENDED_CONVERSATION_OPTIONS,
|
||||
RECOMMENDED_HARM_BLOCK_THRESHOLD,
|
||||
@@ -121,6 +123,12 @@ async def test_form(hass: HomeAssistant) -> None:
|
||||
"title": DEFAULT_TTS_NAME,
|
||||
"unique_id": None,
|
||||
},
|
||||
{
|
||||
"subentry_type": "ai_task_data",
|
||||
"data": RECOMMENDED_AI_TASK_OPTIONS,
|
||||
"title": DEFAULT_AI_TASK_NAME,
|
||||
"unique_id": None,
|
||||
},
|
||||
]
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
@@ -222,7 +230,7 @@ async def test_creating_tts_subentry(
|
||||
assert result2["title"] == "Mock TTS"
|
||||
assert result2["data"] == RECOMMENDED_TTS_OPTIONS
|
||||
|
||||
assert len(mock_config_entry.subentries) == 3
|
||||
assert len(mock_config_entry.subentries) == 4
|
||||
|
||||
new_subentry_id = list(set(mock_config_entry.subentries) - old_subentries)[0]
|
||||
new_subentry = mock_config_entry.subentries[new_subentry_id]
|
||||
@@ -232,13 +240,59 @@ async def test_creating_tts_subentry(
|
||||
assert new_subentry.title == "Mock TTS"
|
||||
|
||||
|
||||
async def test_creating_ai_task_subentry(
|
||||
hass: HomeAssistant,
|
||||
mock_init_component: None,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test creating an AI task subentry."""
|
||||
with patch(
|
||||
"google.genai.models.AsyncModels.list",
|
||||
return_value=get_models_pager(),
|
||||
):
|
||||
result = await hass.config_entries.subentries.async_init(
|
||||
(mock_config_entry.entry_id, "ai_task_data"),
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM, result
|
||||
assert result["step_id"] == "set_options"
|
||||
assert not result["errors"]
|
||||
|
||||
old_subentries = set(mock_config_entry.subentries)
|
||||
|
||||
with patch(
|
||||
"google.genai.models.AsyncModels.list",
|
||||
return_value=get_models_pager(),
|
||||
):
|
||||
result2 = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_NAME: "Mock AI Task", **RECOMMENDED_AI_TASK_OPTIONS},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result2["title"] == "Mock AI Task"
|
||||
assert result2["data"] == RECOMMENDED_AI_TASK_OPTIONS
|
||||
|
||||
assert len(mock_config_entry.subentries) == 4
|
||||
|
||||
new_subentry_id = list(set(mock_config_entry.subentries) - old_subentries)[0]
|
||||
new_subentry = mock_config_entry.subentries[new_subentry_id]
|
||||
|
||||
assert new_subentry.subentry_type == "ai_task_data"
|
||||
assert new_subentry.data == RECOMMENDED_AI_TASK_OPTIONS
|
||||
assert new_subentry.title == "Mock AI Task"
|
||||
|
||||
|
||||
async def test_creating_conversation_subentry_not_loaded(
|
||||
hass: HomeAssistant,
|
||||
mock_init_component: None,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test creating a conversation subentry."""
|
||||
"""Test that subentry fails to init if entry not loaded."""
|
||||
await hass.config_entries.async_unload(mock_config_entry.entry_id)
|
||||
|
||||
with patch(
|
||||
"google.genai.models.AsyncModels.list",
|
||||
return_value=get_models_pager(),
|
||||
|
@@ -8,9 +8,13 @@ from requests.exceptions import Timeout
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.google_generative_ai_conversation.const import (
|
||||
DEFAULT_AI_TASK_NAME,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
DEFAULT_TITLE,
|
||||
DEFAULT_TTS_NAME,
|
||||
DOMAIN,
|
||||
RECOMMENDED_AI_TASK_OPTIONS,
|
||||
RECOMMENDED_CONVERSATION_OPTIONS,
|
||||
RECOMMENDED_TTS_OPTIONS,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState, ConfigSubentryData
|
||||
@@ -397,7 +401,7 @@ async def test_load_entry_with_unloaded_entries(
|
||||
assert [tuple(mock_call) for mock_call in mock_generate.mock_calls] == snapshot
|
||||
|
||||
|
||||
async def test_migration_from_v1_to_v2(
|
||||
async def test_migration_from_v1(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
@@ -473,10 +477,10 @@ async def test_migration_from_v1_to_v2(
|
||||
assert len(entries) == 1
|
||||
entry = entries[0]
|
||||
assert entry.version == 2
|
||||
assert entry.minor_version == 2
|
||||
assert entry.minor_version == 3
|
||||
assert not entry.options
|
||||
assert entry.title == DEFAULT_TITLE
|
||||
assert len(entry.subentries) == 3
|
||||
assert len(entry.subentries) == 4
|
||||
conversation_subentries = [
|
||||
subentry
|
||||
for subentry in entry.subentries.values()
|
||||
@@ -495,6 +499,14 @@ async def test_migration_from_v1_to_v2(
|
||||
assert len(tts_subentries) == 1
|
||||
assert tts_subentries[0].data == RECOMMENDED_TTS_OPTIONS
|
||||
assert tts_subentries[0].title == DEFAULT_TTS_NAME
|
||||
ai_task_subentries = [
|
||||
subentry
|
||||
for subentry in entry.subentries.values()
|
||||
if subentry.subentry_type == "ai_task_data"
|
||||
]
|
||||
assert len(ai_task_subentries) == 1
|
||||
assert ai_task_subentries[0].data == RECOMMENDED_AI_TASK_OPTIONS
|
||||
assert ai_task_subentries[0].title == DEFAULT_AI_TASK_NAME
|
||||
|
||||
subentry = conversation_subentries[0]
|
||||
|
||||
@@ -542,7 +554,7 @@ async def test_migration_from_v1_to_v2(
|
||||
}
|
||||
|
||||
|
||||
async def test_migration_from_v1_to_v2_with_multiple_keys(
|
||||
async def test_migration_from_v1_with_multiple_keys(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
@@ -619,10 +631,10 @@ async def test_migration_from_v1_to_v2_with_multiple_keys(
|
||||
|
||||
for entry in entries:
|
||||
assert entry.version == 2
|
||||
assert entry.minor_version == 2
|
||||
assert entry.minor_version == 3
|
||||
assert not entry.options
|
||||
assert entry.title == DEFAULT_TITLE
|
||||
assert len(entry.subentries) == 2
|
||||
assert len(entry.subentries) == 3
|
||||
subentry = list(entry.subentries.values())[0]
|
||||
assert subentry.subentry_type == "conversation"
|
||||
assert subentry.data == options
|
||||
@@ -631,6 +643,10 @@ async def test_migration_from_v1_to_v2_with_multiple_keys(
|
||||
assert subentry.subentry_type == "tts"
|
||||
assert subentry.data == RECOMMENDED_TTS_OPTIONS
|
||||
assert subentry.title == DEFAULT_TTS_NAME
|
||||
subentry = list(entry.subentries.values())[2]
|
||||
assert subentry.subentry_type == "ai_task_data"
|
||||
assert subentry.data == RECOMMENDED_AI_TASK_OPTIONS
|
||||
assert subentry.title == DEFAULT_AI_TASK_NAME
|
||||
|
||||
dev = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)}
|
||||
@@ -642,7 +658,7 @@ async def test_migration_from_v1_to_v2_with_multiple_keys(
|
||||
}
|
||||
|
||||
|
||||
async def test_migration_from_v1_to_v2_with_same_keys(
|
||||
async def test_migration_from_v1_with_same_keys(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
@@ -718,10 +734,10 @@ async def test_migration_from_v1_to_v2_with_same_keys(
|
||||
assert len(entries) == 1
|
||||
entry = entries[0]
|
||||
assert entry.version == 2
|
||||
assert entry.minor_version == 2
|
||||
assert entry.minor_version == 3
|
||||
assert not entry.options
|
||||
assert entry.title == DEFAULT_TITLE
|
||||
assert len(entry.subentries) == 3
|
||||
assert len(entry.subentries) == 4
|
||||
conversation_subentries = [
|
||||
subentry
|
||||
for subentry in entry.subentries.values()
|
||||
@@ -740,6 +756,14 @@ async def test_migration_from_v1_to_v2_with_same_keys(
|
||||
assert len(tts_subentries) == 1
|
||||
assert tts_subentries[0].data == RECOMMENDED_TTS_OPTIONS
|
||||
assert tts_subentries[0].title == DEFAULT_TTS_NAME
|
||||
ai_task_subentries = [
|
||||
subentry
|
||||
for subentry in entry.subentries.values()
|
||||
if subentry.subentry_type == "ai_task_data"
|
||||
]
|
||||
assert len(ai_task_subentries) == 1
|
||||
assert ai_task_subentries[0].data == RECOMMENDED_AI_TASK_OPTIONS
|
||||
assert ai_task_subentries[0].title == DEFAULT_AI_TASK_NAME
|
||||
|
||||
subentry = conversation_subentries[0]
|
||||
|
||||
@@ -829,7 +853,7 @@ async def test_migration_from_v1_to_v2_with_same_keys(
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_migration_from_v2_1_to_v2_2(
|
||||
async def test_migration_from_v2_1(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
@@ -837,12 +861,13 @@ async def test_migration_from_v2_1_to_v2_2(
|
||||
extra_subentries: list[ConfigSubentryData],
|
||||
expected_device_subentries: dict[str, set[str | None]],
|
||||
) -> None:
|
||||
"""Test migration from version 2.1 to version 2.2.
|
||||
"""Test migration from version 2.1.
|
||||
|
||||
This tests we clean up the broken migration in Home Assistant Core
|
||||
2025.7.0b0-2025.7.0b1:
|
||||
2025.7.0b0-2025.7.0b1 and add AI Task subentry:
|
||||
- Fix device registry (Fixed in Home Assistant Core 2025.7.0b2)
|
||||
- Add TTS subentry (Added in Home Assistant Core 2025.7.0b1)
|
||||
- Add AI Task subentry (Added in version 2.3)
|
||||
"""
|
||||
# Create a v2.1 config entry with 2 subentries, devices and entities
|
||||
options = {
|
||||
@@ -930,10 +955,10 @@ async def test_migration_from_v2_1_to_v2_2(
|
||||
assert len(entries) == 1
|
||||
entry = entries[0]
|
||||
assert entry.version == 2
|
||||
assert entry.minor_version == 2
|
||||
assert entry.minor_version == 3
|
||||
assert not entry.options
|
||||
assert entry.title == DEFAULT_TITLE
|
||||
assert len(entry.subentries) == 3
|
||||
assert len(entry.subentries) == 4
|
||||
conversation_subentries = [
|
||||
subentry
|
||||
for subentry in entry.subentries.values()
|
||||
@@ -952,6 +977,14 @@ async def test_migration_from_v2_1_to_v2_2(
|
||||
assert len(tts_subentries) == 1
|
||||
assert tts_subentries[0].data == RECOMMENDED_TTS_OPTIONS
|
||||
assert tts_subentries[0].title == DEFAULT_TTS_NAME
|
||||
ai_task_subentries = [
|
||||
subentry
|
||||
for subentry in entry.subentries.values()
|
||||
if subentry.subentry_type == "ai_task_data"
|
||||
]
|
||||
assert len(ai_task_subentries) == 1
|
||||
assert ai_task_subentries[0].data == RECOMMENDED_AI_TASK_OPTIONS
|
||||
assert ai_task_subentries[0].title == DEFAULT_AI_TASK_NAME
|
||||
|
||||
subentry = conversation_subentries[0]
|
||||
|
||||
@@ -1011,3 +1044,80 @@ async def test_devices(
|
||||
device_registry, mock_config_entry.entry_id
|
||||
)
|
||||
assert devices == snapshot
|
||||
|
||||
|
||||
async def test_migrate_entry_from_v2_2(hass: HomeAssistant) -> None:
|
||||
"""Test migration from version 2.2."""
|
||||
# Create a v2.2 config entry with conversation and TTS subentries
|
||||
mock_config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_API_KEY: "test-api-key"},
|
||||
version=2,
|
||||
minor_version=2,
|
||||
subentries_data=[
|
||||
{
|
||||
"data": RECOMMENDED_CONVERSATION_OPTIONS,
|
||||
"subentry_type": "conversation",
|
||||
"title": DEFAULT_CONVERSATION_NAME,
|
||||
"unique_id": None,
|
||||
},
|
||||
{
|
||||
"data": RECOMMENDED_TTS_OPTIONS,
|
||||
"subentry_type": "tts",
|
||||
"title": DEFAULT_TTS_NAME,
|
||||
"unique_id": None,
|
||||
},
|
||||
],
|
||||
)
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
# Verify initial state
|
||||
assert mock_config_entry.version == 2
|
||||
assert mock_config_entry.minor_version == 2
|
||||
assert len(mock_config_entry.subentries) == 2
|
||||
|
||||
# Run setup to trigger migration
|
||||
with patch(
|
||||
"homeassistant.components.google_generative_ai_conversation.async_setup_entry",
|
||||
return_value=True,
|
||||
):
|
||||
result = await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
assert result is True
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Verify migration completed
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
assert len(entries) == 1
|
||||
entry = entries[0]
|
||||
|
||||
# Check version and subversion were updated
|
||||
assert entry.version == 2
|
||||
assert entry.minor_version == 3
|
||||
|
||||
# Check we now have conversation, tts and ai_task_data subentries
|
||||
assert len(entry.subentries) == 3
|
||||
|
||||
subentries = {
|
||||
subentry.subentry_type: subentry for subentry in entry.subentries.values()
|
||||
}
|
||||
assert "conversation" in subentries
|
||||
assert "tts" in subentries
|
||||
assert "ai_task_data" in subentries
|
||||
|
||||
# Find and verify the ai_task_data subentry
|
||||
ai_task_subentry = subentries["ai_task_data"]
|
||||
assert ai_task_subentry is not None
|
||||
assert ai_task_subentry.title == DEFAULT_AI_TASK_NAME
|
||||
assert ai_task_subentry.data == RECOMMENDED_AI_TASK_OPTIONS
|
||||
|
||||
# Verify conversation subentry is still there and unchanged
|
||||
conversation_subentry = subentries["conversation"]
|
||||
assert conversation_subentry is not None
|
||||
assert conversation_subentry.title == DEFAULT_CONVERSATION_NAME
|
||||
assert conversation_subentry.data == RECOMMENDED_CONVERSATION_OPTIONS
|
||||
|
||||
# Verify TTS subentry is still there and unchanged
|
||||
tts_subentry = subentries["tts"]
|
||||
assert tts_subentry is not None
|
||||
assert tts_subentry.title == DEFAULT_TTS_NAME
|
||||
assert tts_subentry.data == RECOMMENDED_TTS_OPTIONS
|
||||
|
@@ -25,36 +25,48 @@ async def test_sensors(
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
# Check electric sensors
|
||||
entry = entity_registry.async_get("sensor.current_bill_electric_usage_to_date")
|
||||
entry = entity_registry.async_get(
|
||||
"sensor.elec_account_111111_current_bill_electric_usage_to_date"
|
||||
)
|
||||
assert entry
|
||||
assert entry.unique_id == "pge_111111_elec_usage_to_date"
|
||||
state = hass.states.get("sensor.current_bill_electric_usage_to_date")
|
||||
state = hass.states.get(
|
||||
"sensor.elec_account_111111_current_bill_electric_usage_to_date"
|
||||
)
|
||||
assert state
|
||||
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR
|
||||
assert state.state == "100"
|
||||
|
||||
entry = entity_registry.async_get("sensor.current_bill_electric_cost_to_date")
|
||||
entry = entity_registry.async_get(
|
||||
"sensor.elec_account_111111_current_bill_electric_cost_to_date"
|
||||
)
|
||||
assert entry
|
||||
assert entry.unique_id == "pge_111111_elec_cost_to_date"
|
||||
state = hass.states.get("sensor.current_bill_electric_cost_to_date")
|
||||
state = hass.states.get(
|
||||
"sensor.elec_account_111111_current_bill_electric_cost_to_date"
|
||||
)
|
||||
assert state
|
||||
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "USD"
|
||||
assert state.state == "20.0"
|
||||
|
||||
# Check gas sensors
|
||||
entry = entity_registry.async_get("sensor.current_bill_gas_usage_to_date")
|
||||
entry = entity_registry.async_get(
|
||||
"sensor.gas_account_222222_current_bill_gas_usage_to_date"
|
||||
)
|
||||
assert entry
|
||||
assert entry.unique_id == "pge_222222_gas_usage_to_date"
|
||||
state = hass.states.get("sensor.current_bill_gas_usage_to_date")
|
||||
state = hass.states.get("sensor.gas_account_222222_current_bill_gas_usage_to_date")
|
||||
assert state
|
||||
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS
|
||||
# Convert 50 CCF to m³
|
||||
assert float(state.state) == pytest.approx(50 * 2.83168, abs=1e-3)
|
||||
|
||||
entry = entity_registry.async_get("sensor.current_bill_gas_cost_to_date")
|
||||
entry = entity_registry.async_get(
|
||||
"sensor.gas_account_222222_current_bill_gas_cost_to_date"
|
||||
)
|
||||
assert entry
|
||||
assert entry.unique_id == "pge_222222_gas_cost_to_date"
|
||||
state = hass.states.get("sensor.current_bill_gas_cost_to_date")
|
||||
state = hass.states.get("sensor.gas_account_222222_current_bill_gas_cost_to_date")
|
||||
assert state
|
||||
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "USD"
|
||||
assert state.state == "15.0"
|
||||
|
@@ -1,6 +1,6 @@
|
||||
"""Test the Remote Calendar config flow."""
|
||||
|
||||
from httpx import ConnectError, Response, UnsupportedProtocol
|
||||
from httpx import HTTPError, InvalidURL, Response, TimeoutException
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
@@ -75,10 +75,11 @@ async def test_form_import_webcal(hass: HomeAssistant, ics_content: str) -> None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("side_effect"),
|
||||
("side_effect", "base_error"),
|
||||
[
|
||||
ConnectError("Connection failed"),
|
||||
UnsupportedProtocol("Unsupported protocol"),
|
||||
(TimeoutException("Connection timed out"), "timeout_connect"),
|
||||
(HTTPError("Connection failed"), "cannot_connect"),
|
||||
(InvalidURL("Unsupported protocol"), "cannot_connect"),
|
||||
],
|
||||
)
|
||||
@respx.mock
|
||||
@@ -86,6 +87,7 @@ async def test_form_inavild_url(
|
||||
hass: HomeAssistant,
|
||||
side_effect: Exception,
|
||||
ics_content: str,
|
||||
base_error: str,
|
||||
) -> None:
|
||||
"""Test we get the import form."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
@@ -102,7 +104,7 @@ async def test_form_inavild_url(
|
||||
},
|
||||
)
|
||||
assert result2["type"] is FlowResultType.FORM
|
||||
assert result2["errors"] == {"base": "cannot_connect"}
|
||||
assert result2["errors"] == {"base": base_error}
|
||||
respx.get(CALENDER_URL).mock(
|
||||
return_value=Response(
|
||||
status_code=200,
|
||||
|
@@ -1,6 +1,6 @@
|
||||
"""Tests for init platform of Remote Calendar."""
|
||||
|
||||
from httpx import ConnectError, Response, UnsupportedProtocol
|
||||
from httpx import HTTPError, InvalidURL, Response, TimeoutException
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
@@ -56,8 +56,9 @@ async def test_raise_for_status(
|
||||
@pytest.mark.parametrize(
|
||||
"side_effect",
|
||||
[
|
||||
ConnectError("Connection failed"),
|
||||
UnsupportedProtocol("Unsupported protocol"),
|
||||
TimeoutException("Connection timed out"),
|
||||
HTTPError("Connection failed"),
|
||||
InvalidURL("Unsupported protocol"),
|
||||
ValueError("Invalid response"),
|
||||
],
|
||||
)
|
||||
|
@@ -1,75 +0,0 @@
|
||||
"""Test code shared between test files."""
|
||||
|
||||
from tuyaha.devices import climate, light, switch
|
||||
|
||||
CLIMATE_ID = "1"
|
||||
CLIMATE_DATA = {
|
||||
"data": {"state": "true", "temp_unit": climate.UNIT_CELSIUS},
|
||||
"id": CLIMATE_ID,
|
||||
"ha_type": "climate",
|
||||
"name": "TestClimate",
|
||||
"dev_type": "climate",
|
||||
}
|
||||
|
||||
LIGHT_ID = "2"
|
||||
LIGHT_DATA = {
|
||||
"data": {"state": "true"},
|
||||
"id": LIGHT_ID,
|
||||
"ha_type": "light",
|
||||
"name": "TestLight",
|
||||
"dev_type": "light",
|
||||
}
|
||||
|
||||
SWITCH_ID = "3"
|
||||
SWITCH_DATA = {
|
||||
"data": {"state": True},
|
||||
"id": SWITCH_ID,
|
||||
"ha_type": "switch",
|
||||
"name": "TestSwitch",
|
||||
"dev_type": "switch",
|
||||
}
|
||||
|
||||
LIGHT_ID_FAKE1 = "9998"
|
||||
LIGHT_DATA_FAKE1 = {
|
||||
"data": {"state": "true"},
|
||||
"id": LIGHT_ID_FAKE1,
|
||||
"ha_type": "light",
|
||||
"name": "TestLightFake1",
|
||||
"dev_type": "light",
|
||||
}
|
||||
|
||||
LIGHT_ID_FAKE2 = "9999"
|
||||
LIGHT_DATA_FAKE2 = {
|
||||
"data": {"state": "true"},
|
||||
"id": LIGHT_ID_FAKE2,
|
||||
"ha_type": "light",
|
||||
"name": "TestLightFake2",
|
||||
"dev_type": "light",
|
||||
}
|
||||
|
||||
TUYA_DEVICES = [
|
||||
climate.TuyaClimate(CLIMATE_DATA, None),
|
||||
light.TuyaLight(LIGHT_DATA, None),
|
||||
switch.TuyaSwitch(SWITCH_DATA, None),
|
||||
light.TuyaLight(LIGHT_DATA_FAKE1, None),
|
||||
light.TuyaLight(LIGHT_DATA_FAKE2, None),
|
||||
]
|
||||
|
||||
|
||||
class MockTuya:
|
||||
"""Mock for Tuya devices."""
|
||||
|
||||
def get_all_devices(self):
|
||||
"""Return all configured devices."""
|
||||
return TUYA_DEVICES
|
||||
|
||||
def get_device_by_id(self, dev_id):
|
||||
"""Return configured device with dev id."""
|
||||
if dev_id == LIGHT_ID_FAKE1:
|
||||
return None
|
||||
if dev_id == LIGHT_ID_FAKE2:
|
||||
return switch.TuyaSwitch(SWITCH_DATA, None)
|
||||
for device in TUYA_DEVICES:
|
||||
if device.object_id() == dev_id:
|
||||
return device
|
||||
return None
|
@@ -1139,6 +1139,59 @@ async def test_selector_serializer(
|
||||
"type": "object",
|
||||
"additionalProperties": True,
|
||||
}
|
||||
assert selector_serializer(
|
||||
selector.ObjectSelector(
|
||||
{
|
||||
"fields": {
|
||||
"name": {
|
||||
"required": True,
|
||||
"selector": {"text": {}},
|
||||
},
|
||||
"percentage": {
|
||||
"selector": {"number": {"min": 30, "max": 100}},
|
||||
},
|
||||
},
|
||||
"multiple": False,
|
||||
"label_field": "name",
|
||||
},
|
||||
)
|
||||
) == {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"percentage": {"type": "number", "minimum": 30, "maximum": 100},
|
||||
},
|
||||
}
|
||||
assert selector_serializer(
|
||||
selector.ObjectSelector(
|
||||
{
|
||||
"fields": {
|
||||
"name": {
|
||||
"required": True,
|
||||
"selector": {"text": {}},
|
||||
},
|
||||
"percentage": {
|
||||
"selector": {"number": {"min": 30, "max": 100}},
|
||||
},
|
||||
},
|
||||
"multiple": True,
|
||||
"label_field": "name",
|
||||
},
|
||||
)
|
||||
) == {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"percentage": {
|
||||
"type": "number",
|
||||
"minimum": 30,
|
||||
"maximum": 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
assert selector_serializer(
|
||||
selector.SelectSelector(
|
||||
{
|
||||
|
@@ -5183,7 +5183,7 @@ def test_state_with_unit_and_rounding(
|
||||
|
||||
assert tpl.async_render() == "23.02 beers"
|
||||
assert tpl2.async_render() == "23.015 beers"
|
||||
assert tpl3.async_render() == 23.02
|
||||
assert tpl3.async_render() == 23.015
|
||||
assert tpl4.async_render() == 23.015
|
||||
assert tpl5.async_render() == "23.02 beers"
|
||||
assert tpl6.async_render() == "23.015 beers"
|
||||
|
@@ -738,3 +738,45 @@ async def test_invalid_trigger_platform(
|
||||
await async_setup_component(hass, "test", {})
|
||||
|
||||
assert "Integration test does not provide trigger support, skipping" in caplog.text
|
||||
|
||||
|
||||
@patch("annotatedyaml.loader.load_yaml")
|
||||
@patch.object(Integration, "has_triggers", return_value=True)
|
||||
async def test_subscribe_triggers(
|
||||
mock_has_triggers: Mock,
|
||||
mock_load_yaml: Mock,
|
||||
hass: HomeAssistant,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test trigger.async_subscribe_platform_events."""
|
||||
sun_trigger_descriptions = """
|
||||
sun: {}
|
||||
"""
|
||||
|
||||
def _load_yaml(fname, secrets=None):
|
||||
if fname.endswith("sun/triggers.yaml"):
|
||||
trigger_descriptions = sun_trigger_descriptions
|
||||
else:
|
||||
raise FileNotFoundError
|
||||
with io.StringIO(trigger_descriptions) as file:
|
||||
return parse_yaml(file)
|
||||
|
||||
mock_load_yaml.side_effect = _load_yaml
|
||||
|
||||
async def broken_subscriber(_):
|
||||
"""Simulate a broken subscriber."""
|
||||
raise Exception("Boom!") # noqa: TRY002
|
||||
|
||||
trigger_events = []
|
||||
|
||||
async def good_subscriber(new_triggers: set[str]):
|
||||
"""Simulate a working subscriber."""
|
||||
trigger_events.append(new_triggers)
|
||||
|
||||
trigger.async_subscribe_platform_events(hass, broken_subscriber)
|
||||
trigger.async_subscribe_platform_events(hass, good_subscriber)
|
||||
|
||||
assert await async_setup_component(hass, "sun", {})
|
||||
|
||||
assert trigger_events == [{"sun"}]
|
||||
assert "Error while notifying trigger platform listener" in caplog.text
|
||||
|
@@ -1143,10 +1143,10 @@ CUSTOM_ISSUE_TRACKER = "https://blablabla.com"
|
||||
("hue", "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER_HUE),
|
||||
("hue", None, CORE_ISSUE_TRACKER_HUE),
|
||||
("bla_built_in", None, CORE_ISSUE_TRACKER_BUILT_IN),
|
||||
# Integration domain is not currently deduced from module
|
||||
(None, "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER),
|
||||
(None, "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER_HUE),
|
||||
("hue", "homeassistant.components.mqtt.sensor", CORE_ISSUE_TRACKER_HUE),
|
||||
# Loaded custom integration with known issue tracker
|
||||
(None, "custom_components.bla_custom.sensor", CUSTOM_ISSUE_TRACKER),
|
||||
("bla_custom", "custom_components.bla_custom.sensor", CUSTOM_ISSUE_TRACKER),
|
||||
("bla_custom", None, CUSTOM_ISSUE_TRACKER),
|
||||
# Loaded custom integration without known issue tracker
|
||||
@@ -1155,6 +1155,7 @@ CUSTOM_ISSUE_TRACKER = "https://blablabla.com"
|
||||
("bla_custom_no_tracker", None, None),
|
||||
("hue", "custom_components.bla.sensor", None),
|
||||
# Unloaded custom integration with known issue tracker
|
||||
(None, "custom_components.bla_custom_not_loaded.sensor", CUSTOM_ISSUE_TRACKER),
|
||||
("bla_custom_not_loaded", None, CUSTOM_ISSUE_TRACKER),
|
||||
# Unloaded custom integration without known issue tracker
|
||||
("bla_custom_not_loaded_no_tracker", None, None),
|
||||
@@ -1218,8 +1219,7 @@ async def test_async_get_issue_tracker(
|
||||
("hue", "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER_HUE),
|
||||
("hue", None, CORE_ISSUE_TRACKER_HUE),
|
||||
("bla_built_in", None, CORE_ISSUE_TRACKER_BUILT_IN),
|
||||
# Integration domain is not currently deduced from module
|
||||
(None, "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER),
|
||||
(None, "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER_HUE),
|
||||
("hue", "homeassistant.components.mqtt.sensor", CORE_ISSUE_TRACKER_HUE),
|
||||
# Custom integration with known issue tracker - can't find it without hass
|
||||
("bla_custom", "custom_components.bla_custom.sensor", None),
|
||||
|
Reference in New Issue
Block a user