Compare commits

..

28 Commits

Author SHA1 Message Date
Paulus Schoutsen
9bd7ea78f0 Remove AITaskEntityFeature.GENERATE_STRUCTURED_DATA feature flag
The structured data generation functionality is now available to all
entities that support GENERATE_DATA. This simplifies the API by removing
an unnecessary feature flag while maintaining all functionality.

- Remove GENERATE_STRUCTURED_DATA from AITaskEntityFeature enum
- Remove feature check in task.py
- Update services.yaml to remove filter
- Update tests to reflect the change

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-04 13:22:48 +02:00
Paulus Schoutsen
e42038742a Merge branch 'dev' into ai-task-structured-data 2025-07-04 13:11:00 +02:00
Allen Porter
1fc624c7a7 Update LLM selector serializer to support ObjectSelector fields and arrays (#148094) 2025-07-04 13:05:16 +02:00
tronikos
8641a2141c Fix has-entity-name and entity-translations in Opower (#148098) 2025-07-04 10:10:21 +02:00
Paulus Schoutsen
04cc451c76 Add AI Task platform to Google Gen AI (#146766) 2025-07-03 23:36:34 -07:00
Erik Montnemery
a3b03caead Deduce integration from module in loader.async_get_issue_tracker (#148017) 2025-07-04 07:55:20 +02:00
Allen Porter
ff58c4e564 Merge branch 'ai-task-structured-data' of https://github.com/allenporter/home-assistant-core into ai-task-structured-data 2025-07-04 00:35:20 +00:00
Allen Porter
5ba71a4675 Forbid extra fields in the vol schema to ensure generated output is correct 2025-07-04 00:35:03 +00:00
Allen Porter
7fb7bc8e50 Update conftest.py to revert conftest function 2025-07-03 14:57:52 -07:00
Allen Porter
afa30be64b Rename _validate_structure to _validate_schema 2025-07-03 21:32:10 +00:00
Allen Porter
789eb029fa Add AI task structured output 2025-07-03 21:26:20 +00:00
Franck Nijhof
49d1d781b8 Fix ezviz test timeout (#148066) 2025-07-03 23:11:54 +02:00
HeroOfCanton16
11c75d7ef2 Add sensor attributes restore to modem_callerid integration (#147753) 2025-07-03 22:10:26 +01:00
Arie Catsman
8ef6b62d9a Cancel enphase mac verification on unload. (#148072) 2025-07-03 22:06:38 +02:00
tronikos
b410b414ec Add reconfigure flow in Android TV Remote (#148044) 2025-07-03 22:00:07 +02:00
Arie Catsman
e5f7421703 Bump pyenphase to 2.2.0 (#148070) 2025-07-03 21:04:13 +02:00
Marc Mueller
8330ae2d3a Update license-expression to 30.4.3 (#147941) 2025-07-03 20:22:10 +02:00
tronikos
4b162f09bd Bump androidtvremote2 to 0.2.3 (#148042) 2025-07-03 20:15:47 +02:00
tronikos
9c558fabcd Use AndroidTVRemoteConfigEntry (#148046) 2025-07-03 20:15:36 +02:00
tronikos
5f9cc0a5f6 Add data_description to forms in Android TV Remote (#148045)
Co-authored-by: Franck Nijhof <frenck@frenck.nl>
Co-authored-by: Artem Draft <Drafteed@users.noreply.github.com>
2025-07-03 20:13:44 +02:00
Erik Montnemery
bc4a322e81 Improve helpers.frame.report_usage when called from outside the event loop (#148021) 2025-07-03 20:12:52 +02:00
Jeef
b999c5906e Bump weatherflow4py to 1.4.1 (#148054) 2025-07-03 20:11:33 +02:00
Erik Montnemery
d2825e1c80 Don't gather TRIGGER_PLATFORM_SUBSCRIPTIONS (#147954)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2025-07-03 19:33:28 +02:00
epenet
419e4f3b1d Remove unused module in tuya tests (#148058) 2025-07-03 19:14:27 +02:00
Thomas55555
4a937d2452 Set timeout for remote calendar (#147024) 2025-07-03 10:08:58 -07:00
Noah Husby
01b4a5ceed Bump aiorussound to 4.7.0 (#148057) 2025-07-03 19:04:18 +02:00
Abílio Costa
4e71745c62 Set assist_satellite preannounce default to True (#148060) 2025-07-03 18:41:08 +02:00
Franck Nijhof
6a88ee7a8f Add Task issue form (#148038) 2025-07-03 18:27:51 +02:00
63 changed files with 1354 additions and 236 deletions

51
.github/ISSUE_TEMPLATE/task.yml vendored Normal file
View 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

View 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']
});

View File

@@ -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,

View File

@@ -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."

View File

@@ -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:

View File

@@ -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."
}
}
}

View File

@@ -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)}>"

View File

@@ -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

View File

@@ -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

View File

@@ -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}

View File

@@ -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]

View File

@@ -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)

View File

@@ -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."]
}

View File

@@ -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)

View File

@@ -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:

View File

@@ -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."
}
}
}

View File

@@ -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"): [
{

View File

@@ -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)

View File

@@ -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."

View File

@@ -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
)

View File

@@ -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 "",
)

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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": {

View File

@@ -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."""

View File

@@ -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]
)

View File

@@ -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"
}
}
}
}

View 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),
)

View File

@@ -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)

View File

@@ -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

View File

@@ -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}"

View File

@@ -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."]
}

View File

@@ -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"]
}

View File

@@ -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

View File

@@ -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 = [

View File

@@ -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,

View File

@@ -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:

View File

@@ -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):

View File

@@ -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
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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()

View File

@@ -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"

View File

@@ -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()

View File

@@ -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',

View File

@@ -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>,

View File

@@ -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!"

View File

@@ -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(),

View File

@@ -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

View File

@@ -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"

View File

@@ -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,

View File

@@ -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"),
],
)

View File

@@ -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

View File

@@ -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(
{

View File

@@ -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"

View File

@@ -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

View File

@@ -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),