Merge branch 'dev' of github.com:home-assistant/core into track_entity_changes

This commit is contained in:
abmantis 2025-07-03 23:07:06 +01:00
commit a971313a9d
396 changed files with 9371 additions and 3756 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

@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v3.29.1 uses: github/codeql-action/init@v3.29.2
with: with:
languages: python languages: python
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.29.1 uses: github/codeql-action/analyze@v3.29.2
with: with:
category: "/language:python" category: "/language:python"

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

4
CODEOWNERS generated
View File

@ -452,8 +452,8 @@ build.json @home-assistant/supervisor
/tests/components/eq3btsmart/ @eulemitkeule @dbuezas /tests/components/eq3btsmart/ @eulemitkeule @dbuezas
/homeassistant/components/escea/ @lazdavila /homeassistant/components/escea/ @lazdavila
/tests/components/escea/ @lazdavila /tests/components/escea/ @lazdavila
/homeassistant/components/esphome/ @OttoWinter @jesserockz @kbx81 @bdraco /homeassistant/components/esphome/ @jesserockz @kbx81 @bdraco
/tests/components/esphome/ @OttoWinter @jesserockz @kbx81 @bdraco /tests/components/esphome/ @jesserockz @kbx81 @bdraco
/homeassistant/components/eufylife_ble/ @bdr99 /homeassistant/components/eufylife_ble/ @bdr99
/tests/components/eufylife_ble/ @bdr99 /tests/components/eufylife_ble/ @bdr99
/homeassistant/components/event/ @home-assistant/core /homeassistant/components/event/ @home-assistant/core

View File

@ -1,15 +1,7 @@
FROM mcr.microsoft.com/devcontainers/python:1-3.13 FROM mcr.microsoft.com/vscode/devcontainers/base:debian
SHELL ["/bin/bash", "-o", "pipefail", "-c"] SHELL ["/bin/bash", "-o", "pipefail", "-c"]
# Uninstall pre-installed formatting and linting tools
# They would conflict with our pinned versions
RUN \
pipx uninstall pydocstyle \
&& pipx uninstall pycodestyle \
&& pipx uninstall mypy \
&& pipx uninstall pylint
RUN \ RUN \
curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \ curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \
&& apt-get update \ && apt-get update \
@ -32,21 +24,18 @@ RUN \
libxml2 \ libxml2 \
git \ git \
cmake \ cmake \
autoconf \
&& apt-get clean \ && apt-get clean \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Add go2rtc binary # Add go2rtc binary
COPY --from=ghcr.io/alexxit/go2rtc:latest /usr/local/bin/go2rtc /bin/go2rtc COPY --from=ghcr.io/alexxit/go2rtc:latest /usr/local/bin/go2rtc /bin/go2rtc
# Install uv
RUN pip3 install uv
WORKDIR /usr/src WORKDIR /usr/src
# Setup hass-release COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
RUN git clone --depth 1 https://github.com/home-assistant/hass-release \
&& uv pip install --system -e hass-release/ \ RUN uv python install 3.13.2
&& chown -R vscode /usr/src/hass-release/data
USER vscode USER vscode
ENV VIRTUAL_ENV="/home/vscode/.local/ha-venv" ENV VIRTUAL_ENV="/home/vscode/.local/ha-venv"
@ -55,6 +44,10 @@ ENV PATH="$VIRTUAL_ENV/bin:$PATH"
WORKDIR /tmp WORKDIR /tmp
# Setup hass-release
RUN git clone --depth 1 https://github.com/home-assistant/hass-release ~/hass-release \
&& uv pip install -e ~/hass-release/
# Install Python dependencies from requirements # Install Python dependencies from requirements
COPY requirements.txt ./ COPY requirements.txt ./
COPY homeassistant/package_constraints.txt homeassistant/package_constraints.txt COPY homeassistant/package_constraints.txt homeassistant/package_constraints.txt
@ -65,4 +58,4 @@ RUN uv pip install -r requirements_test.txt
WORKDIR /workspaces WORKDIR /workspaces
# Set the default shell to bash instead of sh # Set the default shell to bash instead of sh
ENV SHELL /bin/bash ENV SHELL=/bin/bash

View File

@ -75,7 +75,6 @@ from .core_config import async_process_ha_core_config
from .exceptions import HomeAssistantError from .exceptions import HomeAssistantError
from .helpers import ( from .helpers import (
area_registry, area_registry,
backup,
category_registry, category_registry,
config_validation as cv, config_validation as cv,
device_registry, device_registry,
@ -880,10 +879,6 @@ async def _async_set_up_integrations(
if "recorder" in all_domains: if "recorder" in all_domains:
recorder.async_initialize_recorder(hass) recorder.async_initialize_recorder(hass)
# Initialize backup
if "backup" in all_domains:
backup.async_initialize_backup(hass)
stages: list[tuple[str, set[str], int | None]] = [ stages: list[tuple[str, set[str], int | None]] = [
*( *(
(name, domain_group, timeout) (name, domain_group, timeout)

View File

@ -2,19 +2,45 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
from typing import Any from typing import Any
from aioamazondevices.api import AmazonEchoApi from aioamazondevices.api import AmazonEchoApi
from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect, WrongCountry
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_CODE, CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_CODE, CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.selector import CountrySelector from homeassistant.helpers.selector import CountrySelector
from .const import CONF_LOGIN_DATA, DOMAIN from .const import CONF_LOGIN_DATA, DOMAIN
STEP_REAUTH_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_PASSWORD): cv.string,
vol.Required(CONF_CODE): cv.string,
}
)
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
"""Validate the user input allows us to connect."""
api = AmazonEchoApi(
data[CONF_COUNTRY],
data[CONF_USERNAME],
data[CONF_PASSWORD],
)
try:
data = await api.login_mode_interactive(data[CONF_CODE])
finally:
await api.close()
return data
class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN): class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Alexa Devices.""" """Handle a config flow for Alexa Devices."""
@ -25,17 +51,14 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle the initial step.""" """Handle the initial step."""
errors = {} errors = {}
if user_input: if user_input:
client = AmazonEchoApi(
user_input[CONF_COUNTRY],
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
)
try: try:
data = await client.login_mode_interactive(user_input[CONF_CODE]) data = await validate_input(self.hass, user_input)
except CannotConnect: except CannotConnect:
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except CannotAuthenticate: except CannotAuthenticate:
errors["base"] = "invalid_auth" errors["base"] = "invalid_auth"
except WrongCountry:
errors["base"] = "wrong_country"
else: else:
await self.async_set_unique_id(data["customer_info"]["user_id"]) await self.async_set_unique_id(data["customer_info"]["user_id"])
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
@ -44,8 +67,6 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
title=user_input[CONF_USERNAME], title=user_input[CONF_USERNAME],
data=user_input | {CONF_LOGIN_DATA: data}, data=user_input | {CONF_LOGIN_DATA: data},
) )
finally:
await client.close()
return self.async_show_form( return self.async_show_form(
step_id="user", step_id="user",
@ -61,3 +82,43 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
} }
), ),
) )
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauth flow."""
self.context["title_placeholders"] = {CONF_USERNAME: entry_data[CONF_USERNAME]}
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauth confirm."""
errors: dict[str, str] = {}
reauth_entry = self._get_reauth_entry()
entry_data = reauth_entry.data
if user_input is not None:
try:
await validate_input(self.hass, {**reauth_entry.data, **user_input})
except CannotConnect:
errors["base"] = "cannot_connect"
except CannotAuthenticate:
errors["base"] = "invalid_auth"
else:
return self.async_update_reload_and_abort(
reauth_entry,
data={
CONF_USERNAME: entry_data[CONF_USERNAME],
CONF_PASSWORD: entry_data[CONF_PASSWORD],
CONF_CODE: user_input[CONF_CODE],
},
)
return self.async_show_form(
step_id="reauth_confirm",
description_placeholders={CONF_USERNAME: entry_data[CONF_USERNAME]},
data_schema=STEP_REAUTH_DATA_SCHEMA,
errors=errors,
)

View File

@ -12,10 +12,10 @@ from aioamazondevices.exceptions import (
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import _LOGGER, CONF_LOGIN_DATA from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN
SCAN_INTERVAL = 30 SCAN_INTERVAL = 30
@ -55,4 +55,8 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
except (CannotConnect, CannotRetrieveData) as err: except (CannotConnect, CannotRetrieveData) as err:
raise UpdateFailed(f"Error occurred while updating {self.name}") from err raise UpdateFailed(f"Error occurred while updating {self.name}") from err
except CannotAuthenticate as err: except CannotAuthenticate as err:
raise ConfigEntryError("Could not authenticate") from err raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err

View File

@ -8,5 +8,5 @@
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["aioamazondevices"], "loggers": ["aioamazondevices"],
"quality_scale": "bronze", "quality_scale": "bronze",
"requirements": ["aioamazondevices==3.1.22"] "requirements": ["aioamazondevices==3.2.2"]
} }

View File

@ -34,7 +34,7 @@ rules:
integration-owner: done integration-owner: done
log-when-unavailable: done log-when-unavailable: done
parallel-updates: done parallel-updates: done
reauthentication-flow: todo reauthentication-flow: done
test-coverage: test-coverage:
status: todo status: todo
comment: all tests missing comment: all tests missing

View File

@ -22,17 +22,29 @@
"password": "[%key:component::alexa_devices::common::data_description_password%]", "password": "[%key:component::alexa_devices::common::data_description_password%]",
"code": "[%key:component::alexa_devices::common::data_description_code%]" "code": "[%key:component::alexa_devices::common::data_description_code%]"
} }
},
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"code": "[%key:component::alexa_devices::common::data_code%]"
},
"data_description": {
"password": "[%key:component::alexa_devices::common::data_description_password%]",
"code": "[%key:component::alexa_devices::common::data_description_code%]"
}
} }
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },
"error": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"wrong_country": "Wrong country selected. Please select the country where your Amazon account is registered.",
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
} }
}, },

View File

@ -5,26 +5,18 @@ from __future__ import annotations
from asyncio import timeout from asyncio import timeout
import logging import logging
from androidtvremote2 import ( from androidtvremote2 import CannotConnect, ConnectionClosed, InvalidAuth
AndroidTVRemote,
CannotConnect,
ConnectionClosed,
InvalidAuth,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import Event, HomeAssistant, callback from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady 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__) _LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.REMOTE] PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.REMOTE]
AndroidTVRemoteConfigEntry = ConfigEntry[AndroidTVRemote]
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: AndroidTVRemoteConfigEntry hass: HomeAssistant, entry: AndroidTVRemoteConfigEntry
@ -82,13 +74,17 @@ async def async_setup_entry(
return True 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.""" """Unload a config entry."""
_LOGGER.debug("async_unload_entry: %s", entry.data) _LOGGER.debug("async_unload_entry: %s", entry.data)
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 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.""" """Handle options update."""
_LOGGER.debug( _LOGGER.debug(
"async_update_options: data: %s options: %s", entry.data, entry.options "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 ( from homeassistant.config_entries import (
SOURCE_REAUTH, SOURCE_REAUTH,
ConfigEntry, SOURCE_RECONFIGURE,
ConfigFlow, ConfigFlow,
ConfigFlowResult, ConfigFlowResult,
OptionsFlow, OptionsFlow,
@ -33,7 +33,7 @@ from homeassistant.helpers.selector import (
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import CONF_APP_ICON, CONF_APP_NAME, CONF_APPS, CONF_ENABLE_IME, DOMAIN 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__) _LOGGER = logging.getLogger(__name__)
@ -41,12 +41,6 @@ APPS_NEW_ID = "NewApp"
CONF_APP_DELETE = "app_delete" CONF_APP_DELETE = "app_delete"
CONF_APP_ID = "app_id" CONF_APP_ID = "app_id"
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required("host"): str,
}
)
STEP_PAIR_DATA_SCHEMA = vol.Schema( STEP_PAIR_DATA_SCHEMA = vol.Schema(
{ {
vol.Required("pin"): str, vol.Required("pin"): str,
@ -67,7 +61,7 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle the initial step.""" """Handle the initial and reconfigure step."""
errors: dict[str, str] = {} errors: dict[str, str] = {}
if user_input is not None: if user_input is not None:
self.host = user_input[CONF_HOST] self.host = user_input[CONF_HOST]
@ -76,15 +70,32 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
await api.async_generate_cert_if_missing() await api.async_generate_cert_if_missing()
self.name, self.mac = await api.async_get_name_and_mac() self.name, self.mac = await api.async_get_name_and_mac()
await self.async_set_unique_id(format_mac(self.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}) self._abort_if_unique_id_configured(updates={CONF_HOST: self.host})
return await self._async_start_pair() return await self._async_start_pair()
except (CannotConnect, ConnectionClosed): except (CannotConnect, ConnectionClosed):
# Likely invalid IP address or device is network unreachable. Stay # Likely invalid IP address or device is network unreachable. Stay
# in the user step allowing the user to enter a different host. # in the user step allowing the user to enter a different host.
errors["base"] = "cannot_connect" 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( return self.async_show_form(
step_id="user", step_id="reconfigure" if self.source == SOURCE_RECONFIGURE else "user",
data_schema=STEP_USER_DATA_SCHEMA, data_schema=vol.Schema(
{vol.Required(CONF_HOST, default=default_host): str}
),
errors=errors, errors=errors,
) )
@ -217,10 +228,16 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors, 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 @staticmethod
@callback @callback
def async_get_options_flow( def async_get_options_flow(
config_entry: ConfigEntry, config_entry: AndroidTVRemoteConfigEntry,
) -> AndroidTVRemoteOptionsFlowHandler: ) -> AndroidTVRemoteOptionsFlowHandler:
"""Create the options flow.""" """Create the options flow."""
return AndroidTVRemoteOptionsFlowHandler(config_entry) return AndroidTVRemoteOptionsFlowHandler(config_entry)
@ -229,7 +246,7 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
class AndroidTVRemoteOptionsFlowHandler(OptionsFlow): class AndroidTVRemoteOptionsFlowHandler(OptionsFlow):
"""Android TV Remote options flow.""" """Android TV Remote options flow."""
def __init__(self, config_entry: ConfigEntry) -> None: def __init__(self, config_entry: AndroidTVRemoteConfigEntry) -> None:
"""Initialize options flow.""" """Initialize options flow."""
self._apps: dict[str, Any] = dict(config_entry.options.get(CONF_APPS, {})) self._apps: dict[str, Any] = dict(config_entry.options.get(CONF_APPS, {}))
self._conf_app_id: str | None = None 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.const import CONF_HOST, CONF_MAC
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from . import AndroidTVRemoteConfigEntry from .helpers import AndroidTVRemoteConfigEntry
TO_REDACT = {CONF_HOST, CONF_MAC} TO_REDACT = {CONF_HOST, CONF_MAC}

View File

@ -6,7 +6,6 @@ from typing import Any
from androidtvremote2 import AndroidTVRemote, ConnectionClosed from androidtvremote2 import AndroidTVRemote, ConnectionClosed
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError 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 homeassistant.helpers.entity import Entity
from .const import CONF_APPS, DOMAIN from .const import CONF_APPS, DOMAIN
from .helpers import AndroidTVRemoteConfigEntry
class AndroidTVRemoteBaseEntity(Entity): class AndroidTVRemoteBaseEntity(Entity):
@ -23,7 +23,9 @@ class AndroidTVRemoteBaseEntity(Entity):
_attr_has_entity_name = True _attr_has_entity_name = True
_attr_should_poll = False _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.""" """Initialize the entity."""
self._api = api self._api = api
self._host = config_entry.data[CONF_HOST] 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 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: def create_api(hass: HomeAssistant, host: str, enable_ime: bool) -> AndroidTVRemote:
"""Create an AndroidTVRemote instance.""" """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.""" """Get value of enable_ime option or its default value."""
return entry.options.get(CONF_ENABLE_IME, CONF_ENABLE_IME_DEFAULT_VALUE) return entry.options.get(CONF_ENABLE_IME, CONF_ENABLE_IME_DEFAULT_VALUE)

View File

@ -7,6 +7,6 @@
"integration_type": "device", "integration_type": "device",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["androidtvremote2"], "loggers": ["androidtvremote2"],
"requirements": ["androidtvremote2==0.2.2"], "requirements": ["androidtvremote2==0.2.3"],
"zeroconf": ["_androidtvremote2._tcp.local."] "zeroconf": ["_androidtvremote2._tcp.local."]
} }

View File

@ -5,7 +5,7 @@ from __future__ import annotations
import asyncio import asyncio
from typing import Any from typing import Any
from androidtvremote2 import AndroidTVRemote, ConnectionClosed from androidtvremote2 import AndroidTVRemote, ConnectionClosed, VolumeInfo
from homeassistant.components.media_player import ( from homeassistant.components.media_player import (
BrowseMedia, BrowseMedia,
@ -20,9 +20,9 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AndroidTVRemoteConfigEntry
from .const import CONF_APP_ICON, CONF_APP_NAME, DOMAIN from .const import CONF_APP_ICON, CONF_APP_NAME, DOMAIN
from .entity import AndroidTVRemoteBaseEntity from .entity import AndroidTVRemoteBaseEntity
from .helpers import AndroidTVRemoteConfigEntry
PARALLEL_UPDATES = 0 PARALLEL_UPDATES = 0
@ -75,13 +75,11 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt
else current_app 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.""" """Update volume info."""
if volume_info.get("max"): if volume_info.get("max"):
self._attr_volume_level = int(volume_info["level"]) / int( self._attr_volume_level = volume_info["level"] / volume_info["max"]
volume_info["max"] self._attr_is_volume_muted = volume_info["muted"]
)
self._attr_is_volume_muted = bool(volume_info["muted"])
else: else:
self._attr_volume_level = None self._attr_volume_level = None
self._attr_is_volume_muted = None self._attr_is_volume_muted = None
@ -93,7 +91,7 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt
self.async_write_ha_state() self.async_write_ha_state()
@callback @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.""" """Update the state when the volume info changes."""
self._update_volume_info(volume_info) self._update_volume_info(volume_info)
self.async_write_ha_state() self.async_write_ha_state()
@ -102,7 +100,9 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt
"""Register callbacks.""" """Register callbacks."""
await super().async_added_to_hass() await super().async_added_to_hass()
if self._api.current_app is not None:
self._update_current_app(self._api.current_app) 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._update_volume_info(self._api.volume_info)
self._api.add_current_app_updated_callback(self._current_app_updated) self._api.add_current_app_updated_callback(self._current_app_updated)

View File

@ -20,9 +20,9 @@ from homeassistant.components.remote import (
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AndroidTVRemoteConfigEntry
from .const import CONF_APP_NAME from .const import CONF_APP_NAME
from .entity import AndroidTVRemoteBaseEntity from .entity import AndroidTVRemoteBaseEntity
from .helpers import AndroidTVRemoteConfigEntry
PARALLEL_UPDATES = 0 PARALLEL_UPDATES = 0
@ -63,6 +63,7 @@ class AndroidTVRemoteEntity(AndroidTVRemoteBaseEntity, RemoteEntity):
self._attr_activity_list = [ self._attr_activity_list = [
app.get(CONF_APP_NAME, "") for app in self._apps.values() app.get(CONF_APP_NAME, "") for app in self._apps.values()
] ]
if self._api.current_app is not None:
self._update_current_app(self._api.current_app) self._update_current_app(self._api.current_app)
self._api.add_current_app_updated_callback(self._current_app_updated) self._api.add_current_app_updated_callback(self._current_app_updated)

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.", "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": { "data": {
"host": "[%key:common::config_flow::data::host%]" "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": { "zeroconf_confirm": {
@ -16,6 +28,9 @@
"description": "Enter the pairing code displayed on the Android TV ({name}).", "description": "Enter the pairing code displayed on the Android TV ({name}).",
"data": { "data": {
"pin": "[%key:common::config_flow::data::pin%]" "pin": "[%key:common::config_flow::data::pin%]"
},
"data_description": {
"pin": "Pairing code displayed on the Android TV device."
} }
}, },
"reauth_confirm": { "reauth_confirm": {
@ -32,7 +47,9 @@
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "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": { "options": {
@ -40,7 +57,11 @@
"init": { "init": {
"data": { "data": {
"apps": "Configure applications list", "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": { "apps": {
@ -53,8 +74,10 @@
"app_delete": "Check to delete this application" "app_delete": "Check to delete this application"
}, },
"data_description": { "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_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

@ -61,6 +61,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) ->
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(async_update_options))
return True return True
@ -69,6 +71,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_update_options(
hass: HomeAssistant, entry: AnthropicConfigEntry
) -> None:
"""Update options."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_migrate_integration(hass: HomeAssistant) -> None: async def async_migrate_integration(hass: HomeAssistant) -> None:
"""Migrate integration entry structure.""" """Migrate integration entry structure."""
@ -138,4 +147,34 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
title=DEFAULT_CONVERSATION_NAME, title=DEFAULT_CONVERSATION_NAME,
options={}, options={},
version=2, version=2,
minor_version=2,
) )
async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> bool:
"""Migrate entry."""
LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
if entry.version > 2:
# This means the user has downgraded from a future version
return False
if entry.version == 2 and entry.minor_version == 1:
# Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1
device_registry = dr.async_get(hass)
for device in dr.async_entries_for_config_entry(
device_registry, entry.entry_id
):
device_registry.async_update_device(
device.id,
remove_config_entry_id=entry.entry_id,
remove_config_subentry_id=None,
)
hass.config_entries.async_update_entry(entry, minor_version=2)
LOGGER.debug(
"Migration to version %s:%s successful", entry.version, entry.minor_version
)
return True

View File

@ -75,6 +75,7 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Anthropic.""" """Handle a config flow for Anthropic."""
VERSION = 2 VERSION = 2
MINOR_VERSION = 2
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None

View File

@ -1,69 +1,17 @@
"""Conversation support for Anthropic.""" """Conversation support for Anthropic."""
from collections.abc import AsyncGenerator, Callable, Iterable from typing import Literal
import json
from typing import Any, Literal, cast
import anthropic
from anthropic import AsyncStream
from anthropic._types import NOT_GIVEN
from anthropic.types import (
InputJSONDelta,
MessageDeltaUsage,
MessageParam,
MessageStreamEvent,
RawContentBlockDeltaEvent,
RawContentBlockStartEvent,
RawContentBlockStopEvent,
RawMessageDeltaEvent,
RawMessageStartEvent,
RawMessageStopEvent,
RedactedThinkingBlock,
RedactedThinkingBlockParam,
SignatureDelta,
TextBlock,
TextBlockParam,
TextDelta,
ThinkingBlock,
ThinkingBlockParam,
ThinkingConfigDisabledParam,
ThinkingConfigEnabledParam,
ThinkingDelta,
ToolParam,
ToolResultBlockParam,
ToolUseBlock,
ToolUseBlockParam,
Usage,
)
from voluptuous_openapi import convert
from homeassistant.components import conversation from homeassistant.components import conversation
from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import intent
from homeassistant.helpers import device_registry as dr, intent, llm
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AnthropicConfigEntry from . import AnthropicConfigEntry
from .const import ( from .const import CONF_PROMPT, DOMAIN
CONF_CHAT_MODEL, from .entity import AnthropicBaseLLMEntity
CONF_MAX_TOKENS,
CONF_PROMPT,
CONF_TEMPERATURE,
CONF_THINKING_BUDGET,
DOMAIN,
LOGGER,
MIN_THINKING_BUDGET,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_THINKING_BUDGET,
THINKING_MODELS,
)
# Max number of back and forth with the LLM to generate a response
MAX_TOOL_ITERATIONS = 10
async def async_setup_entry( async def async_setup_entry(
@ -82,253 +30,10 @@ async def async_setup_entry(
) )
def _format_tool(
tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
) -> ToolParam:
"""Format tool specification."""
return ToolParam(
name=tool.name,
description=tool.description or "",
input_schema=convert(tool.parameters, custom_serializer=custom_serializer),
)
def _convert_content(
chat_content: Iterable[conversation.Content],
) -> list[MessageParam]:
"""Transform HA chat_log content into Anthropic API format."""
messages: list[MessageParam] = []
for content in chat_content:
if isinstance(content, conversation.ToolResultContent):
tool_result_block = ToolResultBlockParam(
type="tool_result",
tool_use_id=content.tool_call_id,
content=json.dumps(content.tool_result),
)
if not messages or messages[-1]["role"] != "user":
messages.append(
MessageParam(
role="user",
content=[tool_result_block],
)
)
elif isinstance(messages[-1]["content"], str):
messages[-1]["content"] = [
TextBlockParam(type="text", text=messages[-1]["content"]),
tool_result_block,
]
else:
messages[-1]["content"].append(tool_result_block) # type: ignore[attr-defined]
elif isinstance(content, conversation.UserContent):
# Combine consequent user messages
if not messages or messages[-1]["role"] != "user":
messages.append(
MessageParam(
role="user",
content=content.content,
)
)
elif isinstance(messages[-1]["content"], str):
messages[-1]["content"] = [
TextBlockParam(type="text", text=messages[-1]["content"]),
TextBlockParam(type="text", text=content.content),
]
else:
messages[-1]["content"].append( # type: ignore[attr-defined]
TextBlockParam(type="text", text=content.content)
)
elif isinstance(content, conversation.AssistantContent):
# Combine consequent assistant messages
if not messages or messages[-1]["role"] != "assistant":
messages.append(
MessageParam(
role="assistant",
content=[],
)
)
if content.content:
messages[-1]["content"].append( # type: ignore[union-attr]
TextBlockParam(type="text", text=content.content)
)
if content.tool_calls:
messages[-1]["content"].extend( # type: ignore[union-attr]
[
ToolUseBlockParam(
type="tool_use",
id=tool_call.id,
name=tool_call.tool_name,
input=tool_call.tool_args,
)
for tool_call in content.tool_calls
]
)
else:
# Note: We don't pass SystemContent here as its passed to the API as the prompt
raise TypeError(f"Unexpected content type: {type(content)}")
return messages
async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place
chat_log: conversation.ChatLog,
result: AsyncStream[MessageStreamEvent],
messages: list[MessageParam],
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
"""Transform the response stream into HA format.
A typical stream of responses might look something like the following:
- RawMessageStartEvent with no content
- RawContentBlockStartEvent with an empty ThinkingBlock (if extended thinking is enabled)
- RawContentBlockDeltaEvent with a ThinkingDelta
- RawContentBlockDeltaEvent with a ThinkingDelta
- RawContentBlockDeltaEvent with a ThinkingDelta
- ...
- RawContentBlockDeltaEvent with a SignatureDelta
- RawContentBlockStopEvent
- RawContentBlockStartEvent with a RedactedThinkingBlock (occasionally)
- RawContentBlockStopEvent (RedactedThinkingBlock does not have a delta)
- RawContentBlockStartEvent with an empty TextBlock
- RawContentBlockDeltaEvent with a TextDelta
- RawContentBlockDeltaEvent with a TextDelta
- RawContentBlockDeltaEvent with a TextDelta
- ...
- RawContentBlockStopEvent
- RawContentBlockStartEvent with ToolUseBlock specifying the function name
- RawContentBlockDeltaEvent with a InputJSONDelta
- RawContentBlockDeltaEvent with a InputJSONDelta
- ...
- RawContentBlockStopEvent
- RawMessageDeltaEvent with a stop_reason='tool_use'
- RawMessageStopEvent(type='message_stop')
Each message could contain multiple blocks of the same type.
"""
if result is None:
raise TypeError("Expected a stream of messages")
current_message: MessageParam | None = None
current_block: (
TextBlockParam
| ToolUseBlockParam
| ThinkingBlockParam
| RedactedThinkingBlockParam
| None
) = None
current_tool_args: str
input_usage: Usage | None = None
async for response in result:
LOGGER.debug("Received response: %s", response)
if isinstance(response, RawMessageStartEvent):
if response.message.role != "assistant":
raise ValueError("Unexpected message role")
current_message = MessageParam(role=response.message.role, content=[])
input_usage = response.message.usage
elif isinstance(response, RawContentBlockStartEvent):
if isinstance(response.content_block, ToolUseBlock):
current_block = ToolUseBlockParam(
type="tool_use",
id=response.content_block.id,
name=response.content_block.name,
input="",
)
current_tool_args = ""
elif isinstance(response.content_block, TextBlock):
current_block = TextBlockParam(
type="text", text=response.content_block.text
)
yield {"role": "assistant"}
if response.content_block.text:
yield {"content": response.content_block.text}
elif isinstance(response.content_block, ThinkingBlock):
current_block = ThinkingBlockParam(
type="thinking",
thinking=response.content_block.thinking,
signature=response.content_block.signature,
)
elif isinstance(response.content_block, RedactedThinkingBlock):
current_block = RedactedThinkingBlockParam(
type="redacted_thinking", data=response.content_block.data
)
LOGGER.debug(
"Some of Claudes internal reasoning has been automatically "
"encrypted for safety reasons. This doesnt affect the quality of "
"responses"
)
elif isinstance(response, RawContentBlockDeltaEvent):
if current_block is None:
raise ValueError("Unexpected delta without a block")
if isinstance(response.delta, InputJSONDelta):
current_tool_args += response.delta.partial_json
elif isinstance(response.delta, TextDelta):
text_block = cast(TextBlockParam, current_block)
text_block["text"] += response.delta.text
yield {"content": response.delta.text}
elif isinstance(response.delta, ThinkingDelta):
thinking_block = cast(ThinkingBlockParam, current_block)
thinking_block["thinking"] += response.delta.thinking
elif isinstance(response.delta, SignatureDelta):
thinking_block = cast(ThinkingBlockParam, current_block)
thinking_block["signature"] += response.delta.signature
elif isinstance(response, RawContentBlockStopEvent):
if current_block is None:
raise ValueError("Unexpected stop event without a current block")
if current_block["type"] == "tool_use":
# tool block
tool_args = json.loads(current_tool_args) if current_tool_args else {}
current_block["input"] = tool_args
yield {
"tool_calls": [
llm.ToolInput(
id=current_block["id"],
tool_name=current_block["name"],
tool_args=tool_args,
)
]
}
elif current_block["type"] == "thinking":
# thinking block
LOGGER.debug("Thinking: %s", current_block["thinking"])
if current_message is None:
raise ValueError("Unexpected stop event without a current message")
current_message["content"].append(current_block) # type: ignore[union-attr]
current_block = None
elif isinstance(response, RawMessageDeltaEvent):
if (usage := response.usage) is not None:
chat_log.async_trace(_create_token_stats(input_usage, usage))
if response.delta.stop_reason == "refusal":
raise HomeAssistantError("Potential policy violation detected")
elif isinstance(response, RawMessageStopEvent):
if current_message is not None:
messages.append(current_message)
current_message = None
def _create_token_stats(
input_usage: Usage | None, response_usage: MessageDeltaUsage
) -> dict[str, Any]:
"""Create token stats for conversation agent tracing."""
input_tokens = 0
cached_input_tokens = 0
if input_usage:
input_tokens = input_usage.input_tokens
cached_input_tokens = input_usage.cache_creation_input_tokens or 0
output_tokens = response_usage.output_tokens
return {
"stats": {
"input_tokens": input_tokens,
"cached_input_tokens": cached_input_tokens,
"output_tokens": output_tokens,
}
}
class AnthropicConversationEntity( class AnthropicConversationEntity(
conversation.ConversationEntity, conversation.AbstractConversationAgent conversation.ConversationEntity,
conversation.AbstractConversationAgent,
AnthropicBaseLLMEntity,
): ):
"""Anthropic conversation agent.""" """Anthropic conversation agent."""
@ -336,17 +41,7 @@ class AnthropicConversationEntity(
def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> None: def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> None:
"""Initialize the agent.""" """Initialize the agent."""
self.entry = entry super().__init__(entry, subentry)
self.subentry = subentry
self._attr_name = subentry.title
self._attr_unique_id = subentry.subentry_id
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, subentry.subentry_id)},
name=subentry.title,
manufacturer="Anthropic",
model="Claude",
entry_type=dr.DeviceEntryType.SERVICE,
)
if self.subentry.data.get(CONF_LLM_HASS_API): if self.subentry.data.get(CONF_LLM_HASS_API):
self._attr_supported_features = ( self._attr_supported_features = (
conversation.ConversationEntityFeature.CONTROL conversation.ConversationEntityFeature.CONTROL
@ -357,13 +52,6 @@ class AnthropicConversationEntity(
"""Return a list of supported languages.""" """Return a list of supported languages."""
return MATCH_ALL return MATCH_ALL
async def async_added_to_hass(self) -> None:
"""When entity is added to Home Assistant."""
await super().async_added_to_hass()
self.entry.async_on_unload(
self.entry.add_update_listener(self._async_entry_update_listener)
)
async def _async_handle_message( async def _async_handle_message(
self, self,
user_input: conversation.ConversationInput, user_input: conversation.ConversationInput,
@ -394,77 +82,3 @@ class AnthropicConversationEntity(
conversation_id=chat_log.conversation_id, conversation_id=chat_log.conversation_id,
continue_conversation=chat_log.continue_conversation, continue_conversation=chat_log.continue_conversation,
) )
async def _async_handle_chat_log(
self,
chat_log: conversation.ChatLog,
) -> None:
"""Generate an answer for the chat log."""
options = self.subentry.data
tools: list[ToolParam] | None = None
if chat_log.llm_api:
tools = [
_format_tool(tool, chat_log.llm_api.custom_serializer)
for tool in chat_log.llm_api.tools
]
system = chat_log.content[0]
if not isinstance(system, conversation.SystemContent):
raise TypeError("First message must be a system message")
messages = _convert_content(chat_log.content[1:])
client = self.entry.runtime_data
thinking_budget = options.get(CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET)
model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
# To prevent infinite loops, we limit the number of iterations
for _iteration in range(MAX_TOOL_ITERATIONS):
model_args = {
"model": model,
"messages": messages,
"tools": tools or NOT_GIVEN,
"max_tokens": options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
"system": system.content,
"stream": True,
}
if model in THINKING_MODELS and thinking_budget >= MIN_THINKING_BUDGET:
model_args["thinking"] = ThinkingConfigEnabledParam(
type="enabled", budget_tokens=thinking_budget
)
else:
model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled")
model_args["temperature"] = options.get(
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
)
try:
stream = await client.messages.create(**model_args)
except anthropic.AnthropicError as err:
raise HomeAssistantError(
f"Sorry, I had a problem talking to Anthropic: {err}"
) from err
messages.extend(
_convert_content(
[
content
async for content in chat_log.async_add_delta_content_stream(
self.entity_id,
_transform_stream(chat_log, stream, messages),
)
if not isinstance(content, conversation.AssistantContent)
]
)
)
if not chat_log.unresponded_tool_results:
break
async def _async_entry_update_listener(
self, hass: HomeAssistant, entry: ConfigEntry
) -> None:
"""Handle options update."""
# Reload as we update device info + entity name + supported features
await hass.config_entries.async_reload(entry.entry_id)

View File

@ -0,0 +1,393 @@
"""Base entity for Anthropic."""
from collections.abc import AsyncGenerator, Callable, Iterable
import json
from typing import Any, cast
import anthropic
from anthropic import AsyncStream
from anthropic._types import NOT_GIVEN
from anthropic.types import (
InputJSONDelta,
MessageDeltaUsage,
MessageParam,
MessageStreamEvent,
RawContentBlockDeltaEvent,
RawContentBlockStartEvent,
RawContentBlockStopEvent,
RawMessageDeltaEvent,
RawMessageStartEvent,
RawMessageStopEvent,
RedactedThinkingBlock,
RedactedThinkingBlockParam,
SignatureDelta,
TextBlock,
TextBlockParam,
TextDelta,
ThinkingBlock,
ThinkingBlockParam,
ThinkingConfigDisabledParam,
ThinkingConfigEnabledParam,
ThinkingDelta,
ToolParam,
ToolResultBlockParam,
ToolUseBlock,
ToolUseBlockParam,
Usage,
)
from voluptuous_openapi import convert
from homeassistant.components import conversation
from homeassistant.config_entries import ConfigSubentry
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, llm
from homeassistant.helpers.entity import Entity
from . import AnthropicConfigEntry
from .const import (
CONF_CHAT_MODEL,
CONF_MAX_TOKENS,
CONF_TEMPERATURE,
CONF_THINKING_BUDGET,
DOMAIN,
LOGGER,
MIN_THINKING_BUDGET,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_THINKING_BUDGET,
THINKING_MODELS,
)
# Max number of back and forth with the LLM to generate a response
MAX_TOOL_ITERATIONS = 10
def _format_tool(
tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
) -> ToolParam:
"""Format tool specification."""
return ToolParam(
name=tool.name,
description=tool.description or "",
input_schema=convert(tool.parameters, custom_serializer=custom_serializer),
)
def _convert_content(
chat_content: Iterable[conversation.Content],
) -> list[MessageParam]:
"""Transform HA chat_log content into Anthropic API format."""
messages: list[MessageParam] = []
for content in chat_content:
if isinstance(content, conversation.ToolResultContent):
tool_result_block = ToolResultBlockParam(
type="tool_result",
tool_use_id=content.tool_call_id,
content=json.dumps(content.tool_result),
)
if not messages or messages[-1]["role"] != "user":
messages.append(
MessageParam(
role="user",
content=[tool_result_block],
)
)
elif isinstance(messages[-1]["content"], str):
messages[-1]["content"] = [
TextBlockParam(type="text", text=messages[-1]["content"]),
tool_result_block,
]
else:
messages[-1]["content"].append(tool_result_block) # type: ignore[attr-defined]
elif isinstance(content, conversation.UserContent):
# Combine consequent user messages
if not messages or messages[-1]["role"] != "user":
messages.append(
MessageParam(
role="user",
content=content.content,
)
)
elif isinstance(messages[-1]["content"], str):
messages[-1]["content"] = [
TextBlockParam(type="text", text=messages[-1]["content"]),
TextBlockParam(type="text", text=content.content),
]
else:
messages[-1]["content"].append( # type: ignore[attr-defined]
TextBlockParam(type="text", text=content.content)
)
elif isinstance(content, conversation.AssistantContent):
# Combine consequent assistant messages
if not messages or messages[-1]["role"] != "assistant":
messages.append(
MessageParam(
role="assistant",
content=[],
)
)
if content.content:
messages[-1]["content"].append( # type: ignore[union-attr]
TextBlockParam(type="text", text=content.content)
)
if content.tool_calls:
messages[-1]["content"].extend( # type: ignore[union-attr]
[
ToolUseBlockParam(
type="tool_use",
id=tool_call.id,
name=tool_call.tool_name,
input=tool_call.tool_args,
)
for tool_call in content.tool_calls
]
)
else:
# Note: We don't pass SystemContent here as its passed to the API as the prompt
raise TypeError(f"Unexpected content type: {type(content)}")
return messages
async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place
chat_log: conversation.ChatLog,
result: AsyncStream[MessageStreamEvent],
messages: list[MessageParam],
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
"""Transform the response stream into HA format.
A typical stream of responses might look something like the following:
- RawMessageStartEvent with no content
- RawContentBlockStartEvent with an empty ThinkingBlock (if extended thinking is enabled)
- RawContentBlockDeltaEvent with a ThinkingDelta
- RawContentBlockDeltaEvent with a ThinkingDelta
- RawContentBlockDeltaEvent with a ThinkingDelta
- ...
- RawContentBlockDeltaEvent with a SignatureDelta
- RawContentBlockStopEvent
- RawContentBlockStartEvent with a RedactedThinkingBlock (occasionally)
- RawContentBlockStopEvent (RedactedThinkingBlock does not have a delta)
- RawContentBlockStartEvent with an empty TextBlock
- RawContentBlockDeltaEvent with a TextDelta
- RawContentBlockDeltaEvent with a TextDelta
- RawContentBlockDeltaEvent with a TextDelta
- ...
- RawContentBlockStopEvent
- RawContentBlockStartEvent with ToolUseBlock specifying the function name
- RawContentBlockDeltaEvent with a InputJSONDelta
- RawContentBlockDeltaEvent with a InputJSONDelta
- ...
- RawContentBlockStopEvent
- RawMessageDeltaEvent with a stop_reason='tool_use'
- RawMessageStopEvent(type='message_stop')
Each message could contain multiple blocks of the same type.
"""
if result is None:
raise TypeError("Expected a stream of messages")
current_message: MessageParam | None = None
current_block: (
TextBlockParam
| ToolUseBlockParam
| ThinkingBlockParam
| RedactedThinkingBlockParam
| None
) = None
current_tool_args: str
input_usage: Usage | None = None
async for response in result:
LOGGER.debug("Received response: %s", response)
if isinstance(response, RawMessageStartEvent):
if response.message.role != "assistant":
raise ValueError("Unexpected message role")
current_message = MessageParam(role=response.message.role, content=[])
input_usage = response.message.usage
elif isinstance(response, RawContentBlockStartEvent):
if isinstance(response.content_block, ToolUseBlock):
current_block = ToolUseBlockParam(
type="tool_use",
id=response.content_block.id,
name=response.content_block.name,
input="",
)
current_tool_args = ""
elif isinstance(response.content_block, TextBlock):
current_block = TextBlockParam(
type="text", text=response.content_block.text
)
yield {"role": "assistant"}
if response.content_block.text:
yield {"content": response.content_block.text}
elif isinstance(response.content_block, ThinkingBlock):
current_block = ThinkingBlockParam(
type="thinking",
thinking=response.content_block.thinking,
signature=response.content_block.signature,
)
elif isinstance(response.content_block, RedactedThinkingBlock):
current_block = RedactedThinkingBlockParam(
type="redacted_thinking", data=response.content_block.data
)
LOGGER.debug(
"Some of Claudes internal reasoning has been automatically "
"encrypted for safety reasons. This doesnt affect the quality of "
"responses"
)
elif isinstance(response, RawContentBlockDeltaEvent):
if current_block is None:
raise ValueError("Unexpected delta without a block")
if isinstance(response.delta, InputJSONDelta):
current_tool_args += response.delta.partial_json
elif isinstance(response.delta, TextDelta):
text_block = cast(TextBlockParam, current_block)
text_block["text"] += response.delta.text
yield {"content": response.delta.text}
elif isinstance(response.delta, ThinkingDelta):
thinking_block = cast(ThinkingBlockParam, current_block)
thinking_block["thinking"] += response.delta.thinking
elif isinstance(response.delta, SignatureDelta):
thinking_block = cast(ThinkingBlockParam, current_block)
thinking_block["signature"] += response.delta.signature
elif isinstance(response, RawContentBlockStopEvent):
if current_block is None:
raise ValueError("Unexpected stop event without a current block")
if current_block["type"] == "tool_use":
# tool block
tool_args = json.loads(current_tool_args) if current_tool_args else {}
current_block["input"] = tool_args
yield {
"tool_calls": [
llm.ToolInput(
id=current_block["id"],
tool_name=current_block["name"],
tool_args=tool_args,
)
]
}
elif current_block["type"] == "thinking":
# thinking block
LOGGER.debug("Thinking: %s", current_block["thinking"])
if current_message is None:
raise ValueError("Unexpected stop event without a current message")
current_message["content"].append(current_block) # type: ignore[union-attr]
current_block = None
elif isinstance(response, RawMessageDeltaEvent):
if (usage := response.usage) is not None:
chat_log.async_trace(_create_token_stats(input_usage, usage))
if response.delta.stop_reason == "refusal":
raise HomeAssistantError("Potential policy violation detected")
elif isinstance(response, RawMessageStopEvent):
if current_message is not None:
messages.append(current_message)
current_message = None
def _create_token_stats(
input_usage: Usage | None, response_usage: MessageDeltaUsage
) -> dict[str, Any]:
"""Create token stats for conversation agent tracing."""
input_tokens = 0
cached_input_tokens = 0
if input_usage:
input_tokens = input_usage.input_tokens
cached_input_tokens = input_usage.cache_creation_input_tokens or 0
output_tokens = response_usage.output_tokens
return {
"stats": {
"input_tokens": input_tokens,
"cached_input_tokens": cached_input_tokens,
"output_tokens": output_tokens,
}
}
class AnthropicBaseLLMEntity(Entity):
"""Anthropic base LLM entity."""
def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> None:
"""Initialize the entity."""
self.entry = entry
self.subentry = subentry
self._attr_name = subentry.title
self._attr_unique_id = subentry.subentry_id
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, subentry.subentry_id)},
name=subentry.title,
manufacturer="Anthropic",
model="Claude",
entry_type=dr.DeviceEntryType.SERVICE,
)
async def _async_handle_chat_log(
self,
chat_log: conversation.ChatLog,
) -> None:
"""Generate an answer for the chat log."""
options = self.subentry.data
tools: list[ToolParam] | None = None
if chat_log.llm_api:
tools = [
_format_tool(tool, chat_log.llm_api.custom_serializer)
for tool in chat_log.llm_api.tools
]
system = chat_log.content[0]
if not isinstance(system, conversation.SystemContent):
raise TypeError("First message must be a system message")
messages = _convert_content(chat_log.content[1:])
client = self.entry.runtime_data
thinking_budget = options.get(CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET)
model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
# To prevent infinite loops, we limit the number of iterations
for _iteration in range(MAX_TOOL_ITERATIONS):
model_args = {
"model": model,
"messages": messages,
"tools": tools or NOT_GIVEN,
"max_tokens": options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
"system": system.content,
"stream": True,
}
if model in THINKING_MODELS and thinking_budget >= MIN_THINKING_BUDGET:
model_args["thinking"] = ThinkingConfigEnabledParam(
type="enabled", budget_tokens=thinking_budget
)
else:
model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled")
model_args["temperature"] = options.get(
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
)
try:
stream = await client.messages.create(**model_args)
except anthropic.AnthropicError as err:
raise HomeAssistantError(
f"Sorry, I had a problem talking to Anthropic: {err}"
) from err
messages.extend(
_convert_content(
[
content
async for content in chat_log.async_add_delta_content_stream(
self.entity_id,
_transform_stream(chat_log, stream, messages),
)
if not isinstance(content, conversation.AssistantContent)
]
)
)
if not chat_log.unresponded_tool_results:
break

View File

@ -71,9 +71,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
cv.make_entity_service_schema( cv.make_entity_service_schema(
{ {
vol.Optional("message"): str, vol.Optional("message"): str,
vol.Optional("media_id"): str, vol.Optional("media_id"): _media_id_validator,
vol.Optional("preannounce"): bool, vol.Optional("preannounce", default=True): bool,
vol.Optional("preannounce_media_id"): str, vol.Optional("preannounce_media_id"): _media_id_validator,
} }
), ),
cv.has_at_least_one_key("message", "media_id"), cv.has_at_least_one_key("message", "media_id"),
@ -81,15 +81,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"async_internal_announce", "async_internal_announce",
[AssistSatelliteEntityFeature.ANNOUNCE], [AssistSatelliteEntityFeature.ANNOUNCE],
) )
component.async_register_entity_service( component.async_register_entity_service(
"start_conversation", "start_conversation",
vol.All( vol.All(
cv.make_entity_service_schema( cv.make_entity_service_schema(
{ {
vol.Optional("start_message"): str, vol.Optional("start_message"): str,
vol.Optional("start_media_id"): str, vol.Optional("start_media_id"): _media_id_validator,
vol.Optional("preannounce"): bool, vol.Optional("preannounce", default=True): bool,
vol.Optional("preannounce_media_id"): str, vol.Optional("preannounce_media_id"): _media_id_validator,
vol.Optional("extra_system_prompt"): str, vol.Optional("extra_system_prompt"): str,
} }
), ),
@ -113,7 +114,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
ask_question_args = { ask_question_args = {
"question": call.data.get("question"), "question": call.data.get("question"),
"question_media_id": call.data.get("question_media_id"), "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"), "answers": call.data.get("answers"),
} }
@ -135,9 +136,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
{ {
vol.Required(ATTR_ENTITY_ID): cv.entity_domain(DOMAIN), vol.Required(ATTR_ENTITY_ID): cv.entity_domain(DOMAIN),
vol.Optional("question"): str, vol.Optional("question"): str,
vol.Optional("question_media_id"): str, vol.Optional("question_media_id"): _media_id_validator,
vol.Optional("preannounce"): bool, vol.Optional("preannounce", default=True): bool,
vol.Optional("preannounce_media_id"): str, vol.Optional("preannounce_media_id"): _media_id_validator,
vol.Optional("answers"): [ vol.Optional("answers"): [
{ {
vol.Required("id"): str, vol.Required("id"): str,
@ -204,3 +205,20 @@ def has_one_non_empty_item(value: list[str]) -> list[str]:
raise vol.Invalid("sentences cannot be empty") raise vol.Invalid("sentences cannot be empty")
return value return value
# Validator for media_id fields that accepts both string and media selector format
_media_id_validator = vol.Any(
cv.string, # Plain string format
vol.All(
vol.Schema(
{
vol.Required("media_content_id"): cv.string,
vol.Required("media_content_type"): cv.string,
vol.Remove("metadata"): dict, # Ignore metadata if present
}
),
# Extract media_content_id from media selector format
lambda x: x["media_content_id"],
),
)

View File

@ -14,7 +14,9 @@ announce:
media_id: media_id:
required: false required: false
selector: selector:
text: media:
accept:
- audio/*
preannounce: preannounce:
required: false required: false
default: true default: true
@ -23,7 +25,9 @@ announce:
preannounce_media_id: preannounce_media_id:
required: false required: false
selector: selector:
text: media:
accept:
- audio/*
start_conversation: start_conversation:
target: target:
entity: entity:
@ -40,7 +44,9 @@ start_conversation:
start_media_id: start_media_id:
required: false required: false
selector: selector:
text: media:
accept:
- audio/*
extra_system_prompt: extra_system_prompt:
required: false required: false
selector: selector:
@ -53,7 +59,9 @@ start_conversation:
preannounce_media_id: preannounce_media_id:
required: false required: false
selector: selector:
text: media:
accept:
- audio/*
ask_question: ask_question:
fields: fields:
entity_id: entity_id:
@ -72,7 +80,9 @@ ask_question:
question_media_id: question_media_id:
required: false required: false
selector: selector:
text: media:
accept:
- audio/*
preannounce: preannounce:
required: false required: false
default: true default: true
@ -81,7 +91,9 @@ ask_question:
preannounce_media_id: preannounce_media_id:
required: false required: false
selector: selector:
text: media:
accept:
- audio/*
answers: answers:
required: false required: false
selector: selector:

View File

@ -2,9 +2,9 @@
from homeassistant.config_entries import SOURCE_SYSTEM from homeassistant.config_entries import SOURCE_SYSTEM
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, discovery_flow from homeassistant.helpers import config_validation as cv, discovery_flow
from homeassistant.helpers.backup import DATA_BACKUP
from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
@ -37,7 +37,6 @@ from .manager import (
IdleEvent, IdleEvent,
IncorrectPasswordError, IncorrectPasswordError,
ManagerBackup, ManagerBackup,
ManagerStateEvent,
NewBackup, NewBackup,
RestoreBackupEvent, RestoreBackupEvent,
RestoreBackupStage, RestoreBackupStage,
@ -72,12 +71,12 @@ __all__ = [
"IncorrectPasswordError", "IncorrectPasswordError",
"LocalBackupAgent", "LocalBackupAgent",
"ManagerBackup", "ManagerBackup",
"ManagerStateEvent",
"NewBackup", "NewBackup",
"RestoreBackupEvent", "RestoreBackupEvent",
"RestoreBackupStage", "RestoreBackupStage",
"RestoreBackupState", "RestoreBackupState",
"WrittenBackup", "WrittenBackup",
"async_get_manager",
"suggested_filename", "suggested_filename",
"suggested_filename_from_name_date", "suggested_filename_from_name_date",
] ]
@ -104,13 +103,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
backup_manager = BackupManager(hass, reader_writer) backup_manager = BackupManager(hass, reader_writer)
hass.data[DATA_MANAGER] = backup_manager hass.data[DATA_MANAGER] = backup_manager
try:
await backup_manager.async_setup() await backup_manager.async_setup()
except Exception as err:
hass.data[DATA_BACKUP].manager_ready.set_exception(err)
raise
else:
hass.data[DATA_BACKUP].manager_ready.set_result(None)
async_register_websocket_handlers(hass, with_hassio) async_register_websocket_handlers(hass, with_hassio)
@ -143,3 +136,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: BackupConfigEntry) -> bo
async def async_unload_entry(hass: HomeAssistant, entry: BackupConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: BackupConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@callback
def async_get_manager(hass: HomeAssistant) -> BackupManager:
"""Get the backup manager instance.
Raises HomeAssistantError if the backup integration is not available.
"""
if DATA_MANAGER not in hass.data:
raise HomeAssistantError("Backup integration is not available")
return hass.data[DATA_MANAGER]

View File

@ -1,38 +0,0 @@
"""Websocket commands for the Backup integration."""
from typing import Any
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.backup import async_subscribe_events
from .const import DATA_MANAGER
from .manager import ManagerStateEvent
@callback
def async_register_websocket_handlers(hass: HomeAssistant) -> None:
"""Register websocket commands."""
websocket_api.async_register_command(hass, handle_subscribe_events)
@websocket_api.require_admin
@websocket_api.websocket_command({vol.Required("type"): "backup/subscribe_events"})
@websocket_api.async_response
async def handle_subscribe_events(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Subscribe to backup events."""
def on_event(event: ManagerStateEvent) -> None:
connection.send_message(websocket_api.event_message(msg["id"], event))
if DATA_MANAGER in hass.data:
manager = hass.data[DATA_MANAGER]
on_event(manager.last_event)
connection.subscriptions[msg["id"]] = async_subscribe_events(hass, on_event)
connection.send_result(msg["id"])

View File

@ -8,10 +8,6 @@ from datetime import datetime
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.backup import (
async_subscribe_events,
async_subscribe_platform_events,
)
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN, LOGGER from .const import DOMAIN, LOGGER
@ -56,8 +52,8 @@ class BackupDataUpdateCoordinator(DataUpdateCoordinator[BackupCoordinatorData]):
update_interval=None, update_interval=None,
) )
self.unsubscribe: list[Callable[[], None]] = [ self.unsubscribe: list[Callable[[], None]] = [
async_subscribe_events(hass, self._on_event), backup_manager.async_subscribe_events(self._on_event),
async_subscribe_platform_events(hass, self._on_event), backup_manager.async_subscribe_platform_events(self._on_event),
] ]
self.backup_manager = backup_manager self.backup_manager = backup_manager

View File

@ -36,7 +36,6 @@ from homeassistant.helpers import (
issue_registry as ir, issue_registry as ir,
start, start,
) )
from homeassistant.helpers.backup import DATA_BACKUP
from homeassistant.helpers.json import json_bytes from homeassistant.helpers.json import json_bytes
from homeassistant.util import dt as dt_util, json as json_util from homeassistant.util import dt as dt_util, json as json_util
@ -372,12 +371,10 @@ class BackupManager:
# Latest backup event and backup event subscribers # Latest backup event and backup event subscribers
self.last_event: ManagerStateEvent = BlockedEvent() self.last_event: ManagerStateEvent = BlockedEvent()
self.last_action_event: ManagerStateEvent | None = None self.last_action_event: ManagerStateEvent | None = None
self._backup_event_subscriptions = hass.data[ self._backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = []
DATA_BACKUP self._backup_platform_event_subscriptions: list[
].backup_event_subscriptions Callable[[BackupPlatformEvent], None]
self._backup_platform_event_subscriptions = hass.data[ ] = []
DATA_BACKUP
].backup_platform_event_subscriptions
async def async_setup(self) -> None: async def async_setup(self) -> None:
"""Set up the backup manager.""" """Set up the backup manager."""
@ -1385,6 +1382,32 @@ class BackupManager:
for subscription in self._backup_event_subscriptions: for subscription in self._backup_event_subscriptions:
subscription(event) subscription(event)
@callback
def async_subscribe_events(
self,
on_event: Callable[[ManagerStateEvent], None],
) -> Callable[[], None]:
"""Subscribe events."""
def remove_subscription() -> None:
self._backup_event_subscriptions.remove(on_event)
self._backup_event_subscriptions.append(on_event)
return remove_subscription
@callback
def async_subscribe_platform_events(
self,
on_event: Callable[[BackupPlatformEvent], None],
) -> Callable[[], None]:
"""Subscribe to backup platform events."""
def remove_subscription() -> None:
self._backup_platform_event_subscriptions.remove(on_event)
self._backup_platform_event_subscriptions.append(on_event)
return remove_subscription
def _create_automatic_backup_failed_issue( def _create_automatic_backup_failed_issue(
self, translation_key: str, translation_placeholders: dict[str, str] | None self, translation_key: str, translation_placeholders: dict[str, str] | None
) -> None: ) -> None:

View File

@ -19,9 +19,14 @@ from homeassistant.components.onboarding import (
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager
from . import BackupManager, Folder, IncorrectPasswordError, http as backup_http from . import (
BackupManager,
Folder,
IncorrectPasswordError,
async_get_manager,
http as backup_http,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from homeassistant.components.onboarding import OnboardingStoreData from homeassistant.components.onboarding import OnboardingStoreData
@ -54,7 +59,7 @@ def with_backup_manager[_ViewT: BaseOnboardingView, **_P](
if self._data["done"]: if self._data["done"]:
raise HTTPUnauthorized raise HTTPUnauthorized
manager = await async_get_backup_manager(request.app[KEY_HASS]) manager = async_get_manager(request.app[KEY_HASS])
return await func(self, manager, request, *args, **kwargs) return await func(self, manager, request, *args, **kwargs)
return with_backup return with_backup

View File

@ -10,7 +10,11 @@ from homeassistant.helpers import config_validation as cv
from .config import Day, ScheduleRecurrence from .config import Day, ScheduleRecurrence
from .const import DATA_MANAGER, LOGGER from .const import DATA_MANAGER, LOGGER
from .manager import DecryptOnDowloadNotSupported, IncorrectPasswordError from .manager import (
DecryptOnDowloadNotSupported,
IncorrectPasswordError,
ManagerStateEvent,
)
from .models import BackupNotFound, Folder from .models import BackupNotFound, Folder
@ -30,6 +34,7 @@ def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) ->
websocket_api.async_register_command(hass, handle_create_with_automatic_settings) websocket_api.async_register_command(hass, handle_create_with_automatic_settings)
websocket_api.async_register_command(hass, handle_delete) websocket_api.async_register_command(hass, handle_delete)
websocket_api.async_register_command(hass, handle_restore) websocket_api.async_register_command(hass, handle_restore)
websocket_api.async_register_command(hass, handle_subscribe_events)
websocket_api.async_register_command(hass, handle_config_info) websocket_api.async_register_command(hass, handle_config_info)
websocket_api.async_register_command(hass, handle_config_update) websocket_api.async_register_command(hass, handle_config_update)
@ -417,3 +422,22 @@ def handle_config_update(
changes.pop("type") changes.pop("type")
manager.config.update(**changes) manager.config.update(**changes)
connection.send_result(msg["id"]) connection.send_result(msg["id"])
@websocket_api.require_admin
@websocket_api.websocket_command({vol.Required("type"): "backup/subscribe_events"})
@websocket_api.async_response
async def handle_subscribe_events(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Subscribe to backup events."""
def on_event(event: ManagerStateEvent) -> None:
connection.send_message(websocket_api.event_message(msg["id"], event))
manager = hass.data[DATA_MANAGER]
on_event(manager.last_event)
connection.subscriptions[msg["id"]] = manager.async_subscribe_events(on_event)
connection.send_result(msg["id"])

View File

@ -19,7 +19,7 @@
"bleak-retry-connector==3.9.0", "bleak-retry-connector==3.9.0",
"bluetooth-adapters==0.21.4", "bluetooth-adapters==0.21.4",
"bluetooth-auto-recovery==1.5.2", "bluetooth-auto-recovery==1.5.2",
"bluetooth-data-tools==1.28.1", "bluetooth-data-tools==1.28.2",
"dbus-fast==2.43.0", "dbus-fast==2.43.0",
"habluetooth==3.49.0" "habluetooth==3.49.0"
] ]

View File

@ -14,7 +14,7 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN
from .services import setup_services from .services import async_setup_services
from .types import BoschAlarmConfigEntry from .types import BoschAlarmConfigEntry
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@ -29,7 +29,7 @@ PLATFORMS: list[Platform] = [
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up bosch alarm services.""" """Set up bosch alarm services."""
setup_services(hass) async_setup_services(hass)
return True return True

View File

@ -9,7 +9,7 @@ from typing import Any
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
@ -66,7 +66,8 @@ async def async_set_panel_date(call: ServiceCall) -> None:
) from err ) from err
def setup_services(hass: HomeAssistant) -> None: @callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up the services for the bosch alarm integration.""" """Set up the services for the bosch alarm integration."""
hass.services.async_register( hass.services.async_register(

View File

@ -13,6 +13,6 @@
"integration_type": "system", "integration_type": "system",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["acme", "hass_nabucasa", "snitun"], "loggers": ["acme", "hass_nabucasa", "snitun"],
"requirements": ["hass-nabucasa==0.104.0"], "requirements": ["hass-nabucasa==0.105.0"],
"single_config_entry": true "single_config_entry": true
} }

View File

@ -6,11 +6,18 @@ from operator import itemgetter
import numpy as np import numpy as np
import voluptuous as vol import voluptuous as vol
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.sensor import (
CONF_STATE_CLASS,
DEVICE_CLASSES_SCHEMA as SENSOR_DEVICE_CLASSES_SCHEMA,
DOMAIN as SENSOR_DOMAIN,
STATE_CLASSES_SCHEMA as SENSOR_STATE_CLASSES_SCHEMA,
)
from homeassistant.const import ( from homeassistant.const import (
CONF_ATTRIBUTE, CONF_ATTRIBUTE,
CONF_DEVICE_CLASS,
CONF_MAXIMUM, CONF_MAXIMUM,
CONF_MINIMUM, CONF_MINIMUM,
CONF_NAME,
CONF_SOURCE, CONF_SOURCE,
CONF_UNIQUE_ID, CONF_UNIQUE_ID,
CONF_UNIT_OF_MEASUREMENT, CONF_UNIT_OF_MEASUREMENT,
@ -50,20 +57,23 @@ def datapoints_greater_than_degree(value: dict) -> dict:
COMPENSATION_SCHEMA = vol.Schema( COMPENSATION_SCHEMA = vol.Schema(
{ {
vol.Required(CONF_SOURCE): cv.entity_id, vol.Optional(CONF_ATTRIBUTE): cv.string,
vol.Required(CONF_DATAPOINTS): [ vol.Required(CONF_DATAPOINTS): [
vol.ExactSequence([vol.Coerce(float), vol.Coerce(float)]) vol.ExactSequence([vol.Coerce(float), vol.Coerce(float)])
], ],
vol.Optional(CONF_UNIQUE_ID): cv.string,
vol.Optional(CONF_ATTRIBUTE): cv.string,
vol.Optional(CONF_UPPER_LIMIT, default=False): cv.boolean,
vol.Optional(CONF_LOWER_LIMIT, default=False): cv.boolean,
vol.Optional(CONF_PRECISION, default=DEFAULT_PRECISION): cv.positive_int,
vol.Optional(CONF_DEGREE, default=DEFAULT_DEGREE): vol.All( vol.Optional(CONF_DEGREE, default=DEFAULT_DEGREE): vol.All(
vol.Coerce(int), vol.Coerce(int),
vol.Range(min=1, max=7), vol.Range(min=1, max=7),
), ),
vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA,
vol.Optional(CONF_LOWER_LIMIT, default=False): cv.boolean,
vol.Optional(CONF_NAME): cv.string,
vol.Optional(CONF_PRECISION, default=DEFAULT_PRECISION): cv.positive_int,
vol.Required(CONF_SOURCE): cv.entity_id,
vol.Optional(CONF_STATE_CLASS): SENSOR_STATE_CLASSES_SCHEMA,
vol.Optional(CONF_UNIQUE_ID): cv.string,
vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
vol.Optional(CONF_UPPER_LIMIT, default=False): cv.boolean,
} }
) )

View File

@ -7,15 +7,23 @@ from typing import Any
import numpy as np import numpy as np
from homeassistant.components.sensor import SensorEntity from homeassistant.components.sensor import (
ATTR_STATE_CLASS,
CONF_STATE_CLASS,
SensorEntity,
)
from homeassistant.const import ( from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_UNIT_OF_MEASUREMENT, ATTR_UNIT_OF_MEASUREMENT,
CONF_ATTRIBUTE, CONF_ATTRIBUTE,
CONF_DEVICE_CLASS,
CONF_MAXIMUM, CONF_MAXIMUM,
CONF_MINIMUM, CONF_MINIMUM,
CONF_NAME,
CONF_SOURCE, CONF_SOURCE,
CONF_UNIQUE_ID, CONF_UNIQUE_ID,
CONF_UNIT_OF_MEASUREMENT, CONF_UNIT_OF_MEASUREMENT,
STATE_UNAVAILABLE,
STATE_UNKNOWN, STATE_UNKNOWN,
) )
from homeassistant.core import ( from homeassistant.core import (
@ -59,24 +67,13 @@ async def async_setup_platform(
source: str = conf[CONF_SOURCE] source: str = conf[CONF_SOURCE]
attribute: str | None = conf.get(CONF_ATTRIBUTE) attribute: str | None = conf.get(CONF_ATTRIBUTE)
if not (name := conf.get(CONF_NAME)):
name = f"{DEFAULT_NAME} {source}" name = f"{DEFAULT_NAME} {source}"
if attribute is not None: if attribute is not None:
name = f"{name} {attribute}" name = f"{name} {attribute}"
async_add_entities( async_add_entities(
[ [CompensationSensor(conf.get(CONF_UNIQUE_ID), name, source, attribute, conf)]
CompensationSensor(
conf.get(CONF_UNIQUE_ID),
name,
source,
attribute,
conf[CONF_PRECISION],
conf[CONF_POLYNOMIAL],
conf.get(CONF_UNIT_OF_MEASUREMENT),
conf[CONF_MINIMUM],
conf[CONF_MAXIMUM],
)
]
) )
@ -91,23 +88,27 @@ class CompensationSensor(SensorEntity):
name: str, name: str,
source: str, source: str,
attribute: str | None, attribute: str | None,
precision: int, config: dict[str, Any],
polynomial: np.poly1d,
unit_of_measurement: str | None,
minimum: tuple[float, float] | None,
maximum: tuple[float, float] | None,
) -> None: ) -> None:
"""Initialize the Compensation sensor.""" """Initialize the Compensation sensor."""
self._attr_name = name
self._source_entity_id = source self._source_entity_id = source
self._precision = precision
self._source_attribute = attribute self._source_attribute = attribute
self._attr_native_unit_of_measurement = unit_of_measurement
self._precision = config[CONF_PRECISION]
self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT)
polynomial: np.poly1d = config[CONF_POLYNOMIAL]
self._poly = polynomial self._poly = polynomial
self._coefficients = polynomial.coefficients.tolist() self._coefficients = polynomial.coefficients.tolist()
self._attr_unique_id = unique_id self._attr_unique_id = unique_id
self._attr_name = name self._minimum = config[CONF_MINIMUM]
self._minimum = minimum self._maximum = config[CONF_MAXIMUM]
self._maximum = maximum
self._attr_device_class = config.get(CONF_DEVICE_CLASS)
self._attr_state_class = config.get(CONF_STATE_CLASS)
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Handle added to Hass.""" """Handle added to Hass."""
@ -137,13 +138,40 @@ class CompensationSensor(SensorEntity):
"""Handle sensor state changes.""" """Handle sensor state changes."""
new_state: State | None new_state: State | None
if (new_state := event.data["new_state"]) is None: if (new_state := event.data["new_state"]) is None:
_LOGGER.warning(
"While updating compensation %s, the new_state is None", self.name
)
self._attr_native_value = None
self.async_write_ha_state()
return return
if new_state.state == STATE_UNKNOWN:
self._attr_native_value = None
self.async_write_ha_state()
return
if new_state.state == STATE_UNAVAILABLE:
self._attr_available = False
self.async_write_ha_state()
return
self._attr_available = True
if self.native_unit_of_measurement is None and self._source_attribute is None: if self.native_unit_of_measurement is None and self._source_attribute is None:
self._attr_native_unit_of_measurement = new_state.attributes.get( self._attr_native_unit_of_measurement = new_state.attributes.get(
ATTR_UNIT_OF_MEASUREMENT ATTR_UNIT_OF_MEASUREMENT
) )
if self._attr_device_class is None and (
device_class := new_state.attributes.get(ATTR_DEVICE_CLASS)
):
self._attr_device_class = device_class
if self._attr_state_class is None and (
state_class := new_state.attributes.get(ATTR_STATE_CLASS)
):
self._attr_state_class = state_class
if self._source_attribute: if self._source_attribute:
value = new_state.attributes.get(self._source_attribute) value = new_state.attributes.get(self._source_attribute)
else: else:

View File

@ -5,8 +5,9 @@ from pycoolmasternet_async import CoolMasterNet
from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.const import CONF_HOST, CONF_PORT, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from .const import CONF_SWING_SUPPORT from .const import CONF_SWING_SUPPORT, DOMAIN
from .coordinator import CoolmasterConfigEntry, CoolmasterDataUpdateCoordinator from .coordinator import CoolmasterConfigEntry, CoolmasterDataUpdateCoordinator
PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR] PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR]
@ -48,3 +49,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: CoolmasterConfigEntry) -
async def async_unload_entry(hass: HomeAssistant, entry: CoolmasterConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: CoolmasterConfigEntry) -> bool:
"""Unload a Coolmaster config entry.""" """Unload a Coolmaster config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_remove_config_entry_device(
hass: HomeAssistant,
config_entry: CoolmasterConfigEntry,
device_entry: dr.DeviceEntry,
) -> bool:
"""Remove a config entry from a device."""
return not device_entry.identifiers.intersection(
(DOMAIN, unit_id) for unit_id in config_entry.runtime_data.data
)

View File

@ -19,10 +19,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
SUPPORT_MINIMAL_SERVICES = VacuumEntityFeature.TURN_ON | VacuumEntityFeature.TURN_OFF SUPPORT_MINIMAL_SERVICES = VacuumEntityFeature.TURN_ON | VacuumEntityFeature.TURN_OFF
SUPPORT_BASIC_SERVICES = ( SUPPORT_BASIC_SERVICES = (
VacuumEntityFeature.STATE VacuumEntityFeature.STATE | VacuumEntityFeature.START | VacuumEntityFeature.STOP
| VacuumEntityFeature.START
| VacuumEntityFeature.STOP
| VacuumEntityFeature.BATTERY
) )
SUPPORT_MOST_SERVICES = ( SUPPORT_MOST_SERVICES = (
@ -31,7 +28,6 @@ SUPPORT_MOST_SERVICES = (
| VacuumEntityFeature.STOP | VacuumEntityFeature.STOP
| VacuumEntityFeature.PAUSE | VacuumEntityFeature.PAUSE
| VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.RETURN_HOME
| VacuumEntityFeature.BATTERY
| VacuumEntityFeature.FAN_SPEED | VacuumEntityFeature.FAN_SPEED
) )
@ -46,7 +42,6 @@ SUPPORT_ALL_SERVICES = (
| VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.SEND_COMMAND
| VacuumEntityFeature.LOCATE | VacuumEntityFeature.LOCATE
| VacuumEntityFeature.STATUS | VacuumEntityFeature.STATUS
| VacuumEntityFeature.BATTERY
| VacuumEntityFeature.LOCATE | VacuumEntityFeature.LOCATE
| VacuumEntityFeature.MAP | VacuumEntityFeature.MAP
| VacuumEntityFeature.CLEAN_SPOT | VacuumEntityFeature.CLEAN_SPOT
@ -90,12 +85,6 @@ class StateDemoVacuum(StateVacuumEntity):
self._attr_activity = VacuumActivity.DOCKED self._attr_activity = VacuumActivity.DOCKED
self._fan_speed = FAN_SPEEDS[1] self._fan_speed = FAN_SPEEDS[1]
self._cleaned_area: float = 0 self._cleaned_area: float = 0
self._battery_level = 100
@property
def battery_level(self) -> int:
"""Return the current battery level of the vacuum."""
return max(0, min(100, self._battery_level))
@property @property
def fan_speed(self) -> str: def fan_speed(self) -> str:
@ -117,7 +106,6 @@ class StateDemoVacuum(StateVacuumEntity):
if self._attr_activity != VacuumActivity.CLEANING: if self._attr_activity != VacuumActivity.CLEANING:
self._attr_activity = VacuumActivity.CLEANING self._attr_activity = VacuumActivity.CLEANING
self._cleaned_area += 1.32 self._cleaned_area += 1.32
self._battery_level -= 1
self.schedule_update_ha_state() self.schedule_update_ha_state()
def pause(self) -> None: def pause(self) -> None:
@ -142,7 +130,6 @@ class StateDemoVacuum(StateVacuumEntity):
"""Perform a spot clean-up.""" """Perform a spot clean-up."""
self._attr_activity = VacuumActivity.CLEANING self._attr_activity = VacuumActivity.CLEANING
self._cleaned_area += 1.32 self._cleaned_area += 1.32
self._battery_level -= 1
self.schedule_update_ha_state() self.schedule_update_ha_state()
def set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: def set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None:

View File

@ -6,5 +6,5 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["pydoods"], "loggers": ["pydoods"],
"quality_scale": "legacy", "quality_scale": "legacy",
"requirements": ["pydoods==1.0.2", "Pillow==11.2.1"] "requirements": ["pydoods==1.0.2", "Pillow==11.3.0"]
} }

View File

@ -92,7 +92,7 @@ SENSORS: list[DROPSensorEntityDescription] = [
native_unit_of_measurement=UnitOfVolume.GALLONS, native_unit_of_measurement=UnitOfVolume.GALLONS,
suggested_display_precision=1, suggested_display_precision=1,
value_fn=lambda device: device.drop_api.water_used_today(), value_fn=lambda device: device.drop_api.water_used_today(),
state_class=SensorStateClass.TOTAL, state_class=SensorStateClass.TOTAL_INCREASING,
), ),
DROPSensorEntityDescription( DROPSensorEntityDescription(
key=AVERAGE_WATER_USED, key=AVERAGE_WATER_USED,

View File

@ -12,7 +12,7 @@ from .bridge import DynaliteBridge
from .const import DOMAIN, LOGGER, PLATFORMS from .const import DOMAIN, LOGGER, PLATFORMS
from .convert_config import convert_config from .convert_config import convert_config
from .panel import async_register_dynalite_frontend from .panel import async_register_dynalite_frontend
from .services import setup_services from .services import async_setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@ -21,7 +21,7 @@ type DynaliteConfigEntry = ConfigEntry[DynaliteBridge]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Dynalite platform.""" """Set up the Dynalite platform."""
setup_services(hass) async_setup_services(hass)
await async_register_dynalite_frontend(hass) await async_register_dynalite_frontend(hass)

View File

@ -50,7 +50,7 @@ async def _request_channel_level(service_call: ServiceCall) -> None:
@callback @callback
def setup_services(hass: HomeAssistant) -> None: def async_setup_services(hass: HomeAssistant) -> None:
"""Set up the Dynalite platform.""" """Set up the Dynalite platform."""
hass.services.async_register( hass.services.async_register(
DOMAIN, DOMAIN,

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/ecovacs", "documentation": "https://www.home-assistant.io/integrations/ecovacs",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"], "loggers": ["sleekxmppfs", "sucks", "deebot_client"],
"requirements": ["py-sucks==0.9.11", "deebot-client==13.4.0"] "requirements": ["py-sucks==0.9.11", "deebot-client==13.5.0"]
} }

View File

@ -8,7 +8,7 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["eheimdigital"], "loggers": ["eheimdigital"],
"quality_scale": "bronze", "quality_scale": "bronze",
"requirements": ["eheimdigital==1.2.0"], "requirements": ["eheimdigital==1.3.0"],
"zeroconf": [ "zeroconf": [
{ "type": "_http._tcp.local.", "name": "eheimdigital._http._tcp.local." } { "type": "_http._tcp.local.", "name": "eheimdigital._http._tcp.local." }
] ]

View File

@ -63,6 +63,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) ->
coordinator = entry.runtime_data coordinator = entry.runtime_data
coordinator.async_cancel_token_refresh() coordinator.async_cancel_token_refresh()
coordinator.async_cancel_firmware_refresh() coordinator.async_cancel_firmware_refresh()
coordinator.async_cancel_mac_verification()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -7,7 +7,7 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["pyenphase"], "loggers": ["pyenphase"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["pyenphase==2.1.0"], "requirements": ["pyenphase==2.2.0"],
"zeroconf": [ "zeroconf": [
{ {
"type": "_enphase-envoy._tcp.local." "type": "_enphase-envoy._tcp.local."

View File

@ -363,7 +363,7 @@
"discharging": "[%key:common::state::discharging%]", "discharging": "[%key:common::state::discharging%]",
"idle": "[%key:common::state::idle%]", "idle": "[%key:common::state::idle%]",
"charging": "[%key:common::state::charging%]", "charging": "[%key:common::state::charging%]",
"full": "Full" "full": "[%key:common::state::full%]"
} }
}, },
"acb_available_energy": { "acb_available_energy": {

View File

@ -281,7 +281,7 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]):
_static_info: _InfoT _static_info: _InfoT
_state: _StateT _state: _StateT
_has_state: bool _has_state: bool = False
unique_id: str unique_id: str
def __init__( def __init__(

View File

@ -2,7 +2,7 @@
"domain": "esphome", "domain": "esphome",
"name": "ESPHome", "name": "ESPHome",
"after_dependencies": ["hassio", "zeroconf", "tag"], "after_dependencies": ["hassio", "zeroconf", "tag"],
"codeowners": ["@OttoWinter", "@jesserockz", "@kbx81", "@bdraco"], "codeowners": ["@jesserockz", "@kbx81", "@bdraco"],
"config_flow": true, "config_flow": true,
"dependencies": ["assist_pipeline", "bluetooth", "intent", "ffmpeg", "http"], "dependencies": ["assist_pipeline", "bluetooth", "intent", "ffmpeg", "http"],
"dhcp": [ "dhcp": [
@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"], "mqtt": ["esphome/discover/#"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": [ "requirements": [
"aioesphomeapi==33.1.1", "aioesphomeapi==34.1.0",
"esphome-dashboard-api==1.3.0", "esphome-dashboard-api==1.3.0",
"bleak-esphome==2.16.0" "bleak-esphome==2.16.0"
], ],

View File

@ -81,6 +81,7 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity):
# if the string is empty # if the string is empty
if unit_of_measurement := static_info.unit_of_measurement: if unit_of_measurement := static_info.unit_of_measurement:
self._attr_native_unit_of_measurement = unit_of_measurement self._attr_native_unit_of_measurement = unit_of_measurement
self._attr_suggested_display_precision = static_info.accuracy_decimals
self._attr_device_class = try_parse_enum( self._attr_device_class = try_parse_enum(
SensorDeviceClass, static_info.device_class SensorDeviceClass, static_info.device_class
) )
@ -97,7 +98,7 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity):
self._attr_state_class = _STATE_CLASSES.from_esphome(state_class) self._attr_state_class = _STATE_CLASSES.from_esphome(state_class)
@property @property
def native_value(self) -> datetime | str | None: def native_value(self) -> datetime | int | float | None:
"""Return the state of the entity.""" """Return the state of the entity."""
if not self._has_state or (state := self._state).missing_state: if not self._has_state or (state := self._state).missing_state:
return None return None
@ -106,7 +107,7 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity):
return None return None
if self.device_class is SensorDeviceClass.TIMESTAMP: if self.device_class is SensorDeviceClass.TIMESTAMP:
return dt_util.utc_from_timestamp(state_float) return dt_util.utc_from_timestamp(state_float)
return f"{state_float:.{self._static_info.accuracy_decimals}f}" return state_float
class EsphomeTextSensor(EsphomeEntity[TextSensorInfo, TextSensorState], SensorEntity): class EsphomeTextSensor(EsphomeEntity[TextSensorInfo, TextSensorState], SensorEntity):

View File

@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend", "documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system", "integration_type": "system",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["home-assistant-frontend==20250627.0"] "requirements": ["home-assistant-frontend==20250702.0"]
} }

View File

@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/generic", "documentation": "https://www.home-assistant.io/integrations/generic",
"integration_type": "device", "integration_type": "device",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["av==13.1.0", "Pillow==11.2.1"] "requirements": ["av==13.1.0", "Pillow==11.3.0"]
} }

View File

@ -308,4 +308,50 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
title=DEFAULT_TITLE, title=DEFAULT_TITLE,
options={}, options={},
version=2, version=2,
minor_version=2,
) )
async def async_migrate_entry(
hass: HomeAssistant, entry: GoogleGenerativeAIConfigEntry
) -> bool:
"""Migrate entry."""
LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
if entry.version > 2:
# This means the user has downgraded from a future version
return False
if entry.version == 2 and entry.minor_version == 1:
# Add TTS subentry which was missing in 2025.7.0b0
if not any(
subentry.subentry_type == "tts" for subentry in entry.subentries.values()
):
hass.config_entries.async_add_subentry(
entry,
ConfigSubentry(
data=MappingProxyType(RECOMMENDED_TTS_OPTIONS),
subentry_type="tts",
title=DEFAULT_TTS_NAME,
unique_id=None,
),
)
# Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1
device_registry = dr.async_get(hass)
for device in dr.async_entries_for_config_entry(
device_registry, entry.entry_id
):
device_registry.async_update_device(
device.id,
remove_config_entry_id=entry.entry_id,
remove_config_subentry_id=None,
)
hass.config_entries.async_update_entry(entry, minor_version=2)
LOGGER.debug(
"Migration to version %s:%s successful", entry.version, entry.minor_version
)
return True

View File

@ -92,6 +92,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Google Generative AI Conversation.""" """Handle a config flow for Google Generative AI Conversation."""
VERSION = 2 VERSION = 2
MINOR_VERSION = 2
async def async_step_api( async def async_step_api(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@ -329,13 +330,14 @@ async def google_generative_ai_config_option_schema(
api_models = [api_model async for api_model in api_models_pager] api_models = [api_model async for api_model in api_models_pager]
models = [ models = [
SelectOptionDict( SelectOptionDict(
label=api_model.display_name, label=api_model.name.lstrip("models/"),
value=api_model.name, value=api_model.name,
) )
for api_model in sorted(api_models, key=lambda x: x.display_name or "") for api_model in sorted(
api_models, key=lambda x: x.name.lstrip("models/") or ""
)
if ( if (
api_model.display_name api_model.name
and api_model.name
and ("tts" in api_model.name) == (subentry_type == "tts") and ("tts" in api_model.name) == (subentry_type == "tts")
and "vision" not in api_model.name and "vision" not in api_model.name
and api_model.supported_actions and api_model.supported_actions

View File

@ -27,7 +27,7 @@ from .const import (
SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED, SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED,
) )
from .coordinator import GuardianDataUpdateCoordinator from .coordinator import GuardianDataUpdateCoordinator
from .services import setup_services from .services import async_setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@ -55,7 +55,7 @@ class GuardianData:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Elexa Guardian component.""" """Set up the Elexa Guardian component."""
setup_services(hass) async_setup_services(hass)
return True return True

View File

@ -122,8 +122,9 @@ async def async_upgrade_firmware(call: ServiceCall, data: GuardianData) -> None:
) )
def setup_services(hass: HomeAssistant) -> None: @callback
"""Register the Renault services.""" def async_setup_services(hass: HomeAssistant) -> None:
"""Register the guardian services."""
for service_name, schema, method in ( for service_name, schema, method in (
( (
SERVICE_NAME_PAIR_SENSOR, SERVICE_NAME_PAIR_SENSOR,

View File

@ -48,13 +48,13 @@ from homeassistant.components.backup import (
RestoreBackupStage, RestoreBackupStage,
RestoreBackupState, RestoreBackupState,
WrittenBackup, WrittenBackup,
async_get_manager as async_get_backup_manager,
suggested_filename as suggested_backup_filename, suggested_filename as suggested_backup_filename,
suggested_filename_from_name_date, suggested_filename_from_name_date,
) )
from homeassistant.const import __version__ as HAVERSION from homeassistant.const import __version__ as HAVERSION
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from homeassistant.util.enum import try_parse_enum from homeassistant.util.enum import try_parse_enum
@ -839,7 +839,7 @@ async def backup_addon_before_update(
async def backup_core_before_update(hass: HomeAssistant) -> None: async def backup_core_before_update(hass: HomeAssistant) -> None:
"""Prepare for updating core.""" """Prepare for updating core."""
backup_manager = await async_get_backup_manager(hass) backup_manager = async_get_backup_manager(hass)
client = get_supervisor_client(hass) client = get_supervisor_client(hass)
try: try:

View File

@ -11,6 +11,7 @@ from urllib.parse import quote
import aiohttp import aiohttp
from aiohttp import ClientTimeout, ClientWebSocketResponse, hdrs, web from aiohttp import ClientTimeout, ClientWebSocketResponse, hdrs, web
from aiohttp.helpers import must_be_empty_body
from aiohttp.web_exceptions import HTTPBadGateway, HTTPBadRequest from aiohttp.web_exceptions import HTTPBadGateway, HTTPBadRequest
from multidict import CIMultiDict from multidict import CIMultiDict
from yarl import URL from yarl import URL
@ -184,12 +185,15 @@ class HassIOIngress(HomeAssistantView):
content_type = "application/octet-stream" content_type = "application/octet-stream"
# Simple request # Simple request
if result.status in (204, 304) or ( if (empty_body := must_be_empty_body(result.method, result.status)) or (
content_length is not UNDEFINED content_length is not UNDEFINED
and (content_length_int := int(content_length)) and (content_length_int := int(content_length))
<= MAX_SIMPLE_RESPONSE_SIZE <= MAX_SIMPLE_RESPONSE_SIZE
): ):
# Return Response # Return Response
if empty_body:
body = None
else:
body = await result.read() body = await result.read()
simple_response = web.Response( simple_response = web.Response(
headers=headers, headers=headers,

View File

@ -9,9 +9,9 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from . import services
from .const import DOMAIN from .const import DOMAIN
from .coordinator import HeosConfigEntry, HeosCoordinator from .coordinator import HeosConfigEntry, HeosCoordinator
from .services import async_setup_services
PLATFORMS = [Platform.MEDIA_PLAYER] PLATFORMS = [Platform.MEDIA_PLAYER]
@ -22,7 +22,7 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the HEOS component.""" """Set up the HEOS component."""
services.register(hass) async_setup_services(hass)
return True return True

View File

@ -9,7 +9,7 @@ import voluptuous as vol
from homeassistant.components.media_player import ATTR_MEDIA_VOLUME_LEVEL from homeassistant.components.media_player import ATTR_MEDIA_VOLUME_LEVEL
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import ( from homeassistant.helpers import (
config_validation as cv, config_validation as cv,
@ -44,7 +44,8 @@ HEOS_SIGN_IN_SCHEMA = vol.Schema(
HEOS_SIGN_OUT_SCHEMA = vol.Schema({}) HEOS_SIGN_OUT_SCHEMA = vol.Schema({})
def register(hass: HomeAssistant) -> None: @callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Register HEOS services.""" """Register HEOS services."""
hass.services.async_register( hass.services.async_register(
DOMAIN, DOMAIN,

View File

@ -23,7 +23,7 @@ from homeassistant.helpers.typing import ConfigType
from .api import AsyncConfigEntryAuth from .api import AsyncConfigEntryAuth
from .const import DOMAIN, OLD_NEW_UNIQUE_ID_SUFFIX_MAP from .const import DOMAIN, OLD_NEW_UNIQUE_ID_SUFFIX_MAP
from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator
from .services import register_actions from .services import async_setup_services
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -43,7 +43,7 @@ PLATFORMS = [
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Home Connect component.""" """Set up Home Connect component."""
register_actions(hass) async_setup_services(hass)
return True return True

View File

@ -18,7 +18,7 @@ from aiohomeconnect.model.error import HomeConnectError
import voluptuous as vol import voluptuous as vol
from homeassistant.const import ATTR_DEVICE_ID from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
@ -522,7 +522,8 @@ async def async_service_start_program(call: ServiceCall) -> None:
await _async_service_program(call, True) await _async_service_program(call, True)
def register_actions(hass: HomeAssistant) -> None: @callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Register custom actions.""" """Register custom actions."""
hass.services.async_register( hass.services.async_register(

View File

@ -27,6 +27,7 @@ from homeassistant.config_entries import (
) )
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.data_entry_flow import AbortFlow from homeassistant.data_entry_flow import AbortFlow
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.hassio import is_hassio
@ -67,6 +68,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
self.addon_start_task: asyncio.Task | None = None self.addon_start_task: asyncio.Task | None = None
self.addon_uninstall_task: asyncio.Task | None = None self.addon_uninstall_task: asyncio.Task | None = None
self.firmware_install_task: asyncio.Task | None = None self.firmware_install_task: asyncio.Task | None = None
self.installing_firmware_name: str | None = None
def _get_translation_placeholders(self) -> dict[str, str]: def _get_translation_placeholders(self) -> dict[str, str]:
"""Shared translation placeholders.""" """Shared translation placeholders."""
@ -152,8 +154,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
assert self._device is not None assert self._device is not None
if not self.firmware_install_task: if not self.firmware_install_task:
# We 100% need to install new firmware only if the wrong firmware is # Keep track of the firmware we're working with, for error messages
# currently installed self.installing_firmware_name = firmware_name
# Installing new firmware is only truly required if the wrong type is
# installed: upgrading to the latest release of the current firmware type
# isn't strictly necessary for functionality.
firmware_install_required = self._probed_firmware_info is None or ( firmware_install_required = self._probed_firmware_info is None or (
self._probed_firmware_info.firmware_type self._probed_firmware_info.firmware_type
!= expected_installed_firmware_type != expected_installed_firmware_type
@ -167,7 +173,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
fw_manifest = next( fw_manifest = next(
fw for fw in manifest.firmwares if fw.filename.startswith(fw_type) fw for fw in manifest.firmwares if fw.filename.startswith(fw_type)
) )
except (StopIteration, TimeoutError, ClientError, ManifestMissing) as err: except (StopIteration, TimeoutError, ClientError, ManifestMissing):
_LOGGER.warning( _LOGGER.warning(
"Failed to fetch firmware update manifest", exc_info=True "Failed to fetch firmware update manifest", exc_info=True
) )
@ -179,13 +185,9 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
) )
return self.async_show_progress_done(next_step_id=next_step_id) return self.async_show_progress_done(next_step_id=next_step_id)
raise AbortFlow( return self.async_show_progress_done(
"fw_download_failed", next_step_id="firmware_download_failed"
description_placeholders={ )
**self._get_translation_placeholders(),
"firmware_name": firmware_name,
},
) from err
if not firmware_install_required: if not firmware_install_required:
assert self._probed_firmware_info is not None assert self._probed_firmware_info is not None
@ -205,7 +207,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
try: try:
fw_data = await client.async_fetch_firmware(fw_manifest) fw_data = await client.async_fetch_firmware(fw_manifest)
except (TimeoutError, ClientError, ValueError) as err: except (TimeoutError, ClientError, ValueError):
_LOGGER.warning("Failed to fetch firmware update", exc_info=True) _LOGGER.warning("Failed to fetch firmware update", exc_info=True)
# If we cannot download new firmware, we shouldn't block setup # If we cannot download new firmware, we shouldn't block setup
@ -216,13 +218,9 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
return self.async_show_progress_done(next_step_id=next_step_id) return self.async_show_progress_done(next_step_id=next_step_id)
# Otherwise, fail # Otherwise, fail
raise AbortFlow( return self.async_show_progress_done(
"fw_download_failed", next_step_id="firmware_download_failed"
description_placeholders={ )
**self._get_translation_placeholders(),
"firmware_name": firmware_name,
},
) from err
self.firmware_install_task = self.hass.async_create_task( self.firmware_install_task = self.hass.async_create_task(
async_flash_silabs_firmware( async_flash_silabs_firmware(
@ -249,8 +247,40 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
progress_task=self.firmware_install_task, progress_task=self.firmware_install_task,
) )
try:
await self.firmware_install_task
except HomeAssistantError:
_LOGGER.exception("Failed to flash firmware")
return self.async_show_progress_done(next_step_id="firmware_install_failed")
return self.async_show_progress_done(next_step_id=next_step_id) return self.async_show_progress_done(next_step_id=next_step_id)
async def async_step_firmware_download_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort when firmware download failed."""
assert self.installing_firmware_name is not None
return self.async_abort(
reason="fw_download_failed",
description_placeholders={
**self._get_translation_placeholders(),
"firmware_name": self.installing_firmware_name,
},
)
async def async_step_firmware_install_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort when firmware install failed."""
assert self.installing_firmware_name is not None
return self.async_abort(
reason="fw_install_failed",
description_placeholders={
**self._get_translation_placeholders(),
"firmware_name": self.installing_firmware_name,
},
)
async def async_step_pick_firmware_zigbee( async def async_step_pick_firmware_zigbee(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:

View File

@ -37,7 +37,8 @@
"zha_still_using_stick": "This {model} is in use by the Zigbee Home Automation integration. Please migrate your Zigbee network to another adapter or delete the integration and try again.", "zha_still_using_stick": "This {model} is in use by the Zigbee Home Automation integration. Please migrate your Zigbee network to another adapter or delete the integration and try again.",
"otbr_still_using_stick": "This {model} is in use by the OpenThread Border Router add-on. If you use the Thread network, make sure you have alternative border routers. Uninstall the add-on and try again.", "otbr_still_using_stick": "This {model} is in use by the OpenThread Border Router add-on. If you use the Thread network, make sure you have alternative border routers. Uninstall the add-on and try again.",
"unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device.", "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device.",
"fw_download_failed": "{firmware_name} firmware for your {model} failed to download. Make sure Home Assistant has internet access and try again." "fw_download_failed": "{firmware_name} firmware for your {model} failed to download. Make sure Home Assistant has internet access and try again.",
"fw_install_failed": "{firmware_name} firmware failed to install, check Home Assistant logs for more information."
}, },
"progress": { "progress": {
"install_firmware": "Please wait while {firmware_name} firmware is installed to your {model}, this will take a few minutes. Do not make any changes to your hardware or software until this finishes." "install_firmware": "Please wait while {firmware_name} firmware is installed to your {model}, this will take a few minutes. Do not make any changes to your hardware or software until this finishes."

View File

@ -93,7 +93,8 @@
"zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]", "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]",
"otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]",
"unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]", "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]",
"fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]" "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]",
"fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]"
}, },
"progress": { "progress": {
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
@ -147,7 +148,8 @@
"zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]", "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]",
"otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]",
"unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]", "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]",
"fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]" "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]",
"fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]"
}, },
"progress": { "progress": {
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",

View File

@ -118,7 +118,8 @@
"zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]", "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]",
"otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]",
"unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device.", "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device.",
"fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]" "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]",
"fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]"
}, },
"progress": { "progress": {
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",

View File

@ -63,8 +63,8 @@ from .utils import get_device_macs, non_verifying_requests_session
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): class HuaweiLteConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle Huawei LTE config flow.""" """Huawei LTE config flow."""
VERSION = 3 VERSION = 3
@ -75,9 +75,9 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
@callback @callback
def async_get_options_flow( def async_get_options_flow(
config_entry: ConfigEntry, config_entry: ConfigEntry,
) -> OptionsFlowHandler: ) -> HuaweiLteOptionsFlow:
"""Get options flow.""" """Get options flow."""
return OptionsFlowHandler() return HuaweiLteOptionsFlow()
async def _async_show_user_form( async def _async_show_user_form(
self, self,
@ -354,7 +354,7 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_update_reload_and_abort(entry, data=new_data) return self.async_update_reload_and_abort(entry, data=new_data)
class OptionsFlowHandler(OptionsFlow): class HuaweiLteOptionsFlow(OptionsFlow):
"""Huawei LTE options flow.""" """Huawei LTE options flow."""
async def async_step_init( async def async_step_init(

View File

@ -73,7 +73,6 @@ class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity):
schedule = self.mower_attributes.calendar schedule = self.mower_attributes.calendar
cursor = schedule.timeline.active_after(dt_util.now()) cursor = schedule.timeline.active_after(dt_util.now())
program_event = next(cursor, None) program_event = next(cursor, None)
_LOGGER.debug("program_event %s", program_event)
if not program_event: if not program_event:
return None return None
work_area_name = None work_area_name = None

View File

@ -1,7 +1,19 @@
"""The constants for the Husqvarna Automower integration.""" """The constants for the Husqvarna Automower integration."""
from aioautomower.model import MowerStates
DOMAIN = "husqvarna_automower" DOMAIN = "husqvarna_automower"
EXECUTION_TIME_DELAY = 5 EXECUTION_TIME_DELAY = 5
NAME = "Husqvarna Automower" NAME = "Husqvarna Automower"
OAUTH2_AUTHORIZE = "https://api.authentication.husqvarnagroup.dev/v1/oauth2/authorize" OAUTH2_AUTHORIZE = "https://api.authentication.husqvarnagroup.dev/v1/oauth2/authorize"
OAUTH2_TOKEN = "https://api.authentication.husqvarnagroup.dev/v1/oauth2/token" OAUTH2_TOKEN = "https://api.authentication.husqvarnagroup.dev/v1/oauth2/token"
ERROR_STATES = [
MowerStates.ERROR_AT_POWER_UP,
MowerStates.ERROR,
MowerStates.FATAL_ERROR,
MowerStates.OFF,
MowerStates.STOPPED,
MowerStates.WAIT_POWER_UP,
MowerStates.WAIT_UPDATING,
]

View File

@ -18,7 +18,7 @@ from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AutomowerConfigEntry from . import AutomowerConfigEntry
from .const import DOMAIN from .const import DOMAIN, ERROR_STATES
from .coordinator import AutomowerDataUpdateCoordinator from .coordinator import AutomowerDataUpdateCoordinator
from .entity import AutomowerAvailableEntity, handle_sending_exception from .entity import AutomowerAvailableEntity, handle_sending_exception
@ -108,18 +108,28 @@ class AutomowerLawnMowerEntity(AutomowerAvailableEntity, LawnMowerEntity):
def activity(self) -> LawnMowerActivity: def activity(self) -> LawnMowerActivity:
"""Return the state of the mower.""" """Return the state of the mower."""
mower_attributes = self.mower_attributes mower_attributes = self.mower_attributes
if mower_attributes.mower.state in ERROR_STATES:
return LawnMowerActivity.ERROR
if mower_attributes.mower.state in PAUSED_STATES: if mower_attributes.mower.state in PAUSED_STATES:
return LawnMowerActivity.PAUSED return LawnMowerActivity.PAUSED
if (mower_attributes.mower.state == "RESTRICTED") or ( if mower_attributes.mower.activity == MowerActivities.GOING_HOME:
mower_attributes.mower.activity in DOCKED_ACTIVITIES return LawnMowerActivity.RETURNING
if (
mower_attributes.mower.state is MowerStates.RESTRICTED
or mower_attributes.mower.activity in DOCKED_ACTIVITIES
): ):
return LawnMowerActivity.DOCKED return LawnMowerActivity.DOCKED
if mower_attributes.mower.state in MowerStates.IN_OPERATION: if mower_attributes.mower.state in MowerStates.IN_OPERATION:
if mower_attributes.mower.activity == MowerActivities.GOING_HOME:
return LawnMowerActivity.RETURNING
return LawnMowerActivity.MOWING return LawnMowerActivity.MOWING
return LawnMowerActivity.ERROR return LawnMowerActivity.ERROR
@property
def available(self) -> bool:
"""Return the available attribute of the entity."""
return (
super().available and self.mower_attributes.mower.state != MowerStates.OFF
)
@property @property
def work_areas(self) -> dict[int, WorkArea] | None: def work_areas(self) -> dict[int, WorkArea] | None:
"""Return the work areas of the mower.""" """Return the work areas of the mower."""

View File

@ -7,13 +7,7 @@ import logging
from operator import attrgetter from operator import attrgetter
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from aioautomower.model import ( from aioautomower.model import MowerAttributes, MowerModes, RestrictedReasons, WorkArea
MowerAttributes,
MowerModes,
MowerStates,
RestrictedReasons,
WorkArea,
)
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
@ -27,6 +21,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType from homeassistant.helpers.typing import StateType
from . import AutomowerConfigEntry from . import AutomowerConfigEntry
from .const import ERROR_STATES
from .coordinator import AutomowerDataUpdateCoordinator from .coordinator import AutomowerDataUpdateCoordinator
from .entity import ( from .entity import (
AutomowerBaseEntity, AutomowerBaseEntity,
@ -166,15 +161,6 @@ ERROR_KEYS = [
"zone_generator_problem", "zone_generator_problem",
] ]
ERROR_STATES = [
MowerStates.ERROR_AT_POWER_UP,
MowerStates.ERROR,
MowerStates.FATAL_ERROR,
MowerStates.OFF,
MowerStates.STOPPED,
MowerStates.WAIT_POWER_UP,
MowerStates.WAIT_UPDATING,
]
ERROR_KEY_LIST = list( ERROR_KEY_LIST = list(
dict.fromkeys(ERROR_KEYS + [state.lower() for state in ERROR_STATES]) dict.fromkeys(ERROR_KEYS + [state.lower() for state in ERROR_STATES])

View File

@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/image_upload", "documentation": "https://www.home-assistant.io/integrations/image_upload",
"integration_type": "system", "integration_type": "system",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["Pillow==11.2.1"] "requirements": ["Pillow==11.3.0"]
} }

View File

@ -209,6 +209,12 @@
"state": { "state": {
"off": "mdi:card-bulleted-off-outline" "off": "mdi:card-bulleted-off-outline"
} }
},
"boost": {
"default": "mdi:thermometer-high",
"state": {
"off": "mdi:thermometer-off"
}
} }
} }
} }

View File

@ -464,6 +464,16 @@ class IronOSTemperatureNumberEntity(IronOSNumberEntity):
else super().native_max_value else super().native_max_value
) )
@property
def available(self) -> bool:
"""Return True if entity is available."""
if (
self.entity_description.key is PinecilNumber.BOOST_TEMP
and self.native_value == 0
):
return False
return super().available
class IronOSSetpointNumberEntity(IronOSTemperatureNumberEntity): class IronOSSetpointNumberEntity(IronOSTemperatureNumberEntity):
"""IronOS setpoint temperature entity.""" """IronOS setpoint temperature entity."""

View File

@ -278,6 +278,9 @@
}, },
"calibrate_cjc": { "calibrate_cjc": {
"name": "Calibrate CJC" "name": "Calibrate CJC"
},
"boost": {
"name": "Boost"
} }
} }
}, },

View File

@ -7,7 +7,7 @@ from dataclasses import dataclass
from enum import StrEnum from enum import StrEnum
from typing import Any from typing import Any
from pynecil import CharSetting, SettingsDataResponse from pynecil import CharSetting, SettingsDataResponse, TempUnit
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory from homeassistant.const import EntityCategory
@ -15,6 +15,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import IronOSConfigEntry from . import IronOSConfigEntry
from .const import MIN_BOOST_TEMP, MIN_BOOST_TEMP_F
from .coordinator import IronOSCoordinators from .coordinator import IronOSCoordinators
from .entity import IronOSBaseEntity from .entity import IronOSBaseEntity
@ -39,6 +40,7 @@ class IronOSSwitch(StrEnum):
INVERT_BUTTONS = "invert_buttons" INVERT_BUTTONS = "invert_buttons"
DISPLAY_INVERT = "display_invert" DISPLAY_INVERT = "display_invert"
CALIBRATE_CJC = "calibrate_cjc" CALIBRATE_CJC = "calibrate_cjc"
BOOST = "boost"
SWITCH_DESCRIPTIONS: tuple[IronOSSwitchEntityDescription, ...] = ( SWITCH_DESCRIPTIONS: tuple[IronOSSwitchEntityDescription, ...] = (
@ -94,6 +96,13 @@ SWITCH_DESCRIPTIONS: tuple[IronOSSwitchEntityDescription, ...] = (
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
), ),
IronOSSwitchEntityDescription(
key=IronOSSwitch.BOOST,
translation_key=IronOSSwitch.BOOST,
characteristic=CharSetting.BOOST_TEMP,
is_on_fn=lambda x: bool(x.get("boost_temp")),
entity_category=EntityCategory.CONFIG,
),
) )
@ -136,6 +145,14 @@ class IronOSSwitchEntity(IronOSBaseEntity, SwitchEntity):
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on.""" """Turn the entity on."""
if self.entity_description.key is IronOSSwitch.BOOST:
await self.settings.write(
self.entity_description.characteristic,
MIN_BOOST_TEMP_F
if self.settings.data.get("temp_unit") is TempUnit.FAHRENHEIT
else MIN_BOOST_TEMP,
)
else:
await self.settings.write(self.entity_description.characteristic, True) await self.settings.write(self.entity_description.characteristic, True)
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:

View File

@ -108,22 +108,22 @@ def get_statistics(
if monthly_consumptions := get_consumptions(data, value_type): if monthly_consumptions := get_consumptions(data, value_type):
return [ return [
{ {
"value": as_number( "value": as_number(value),
get_values_by_type(
consumptions=consumptions,
consumption_type=consumption_type,
).get(
"additionalValue"
if value_type == IstaValueType.ENERGY
else "value"
)
),
"date": consumptions["date"], "date": consumptions["date"],
} }
for consumptions in monthly_consumptions for consumptions in monthly_consumptions
if get_values_by_type( if (
value := (
consumption := get_values_by_type(
consumptions=consumptions, consumptions=consumptions,
consumption_type=consumption_type, consumption_type=consumption_type,
).get("additionalValue" if value_type == IstaValueType.ENERGY else "value") )
).get(
"additionalValue"
if value_type == IstaValueType.ENERGY
and consumption.get("additionalValue") is not None
else "value"
)
)
] ]
return None return None

View File

@ -91,7 +91,7 @@ from .schema import (
TimeSchema, TimeSchema,
WeatherSchema, WeatherSchema,
) )
from .services import register_knx_services from .services import async_setup_services
from .storage.config_store import STORAGE_KEY as CONFIG_STORAGE_KEY, KNXConfigStore from .storage.config_store import STORAGE_KEY as CONFIG_STORAGE_KEY, KNXConfigStore
from .telegrams import STORAGE_KEY as TELEGRAMS_STORAGE_KEY, Telegrams from .telegrams import STORAGE_KEY as TELEGRAMS_STORAGE_KEY, Telegrams
from .websocket import register_panel from .websocket import register_panel
@ -138,7 +138,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
if (conf := config.get(DOMAIN)) is not None: if (conf := config.get(DOMAIN)) is not None:
hass.data[_KNX_YAML_CONFIG] = dict(conf) hass.data[_KNX_YAML_CONFIG] = dict(conf)
register_knx_services(hass) async_setup_services(hass)
return True return True

View File

@ -39,7 +39,8 @@ from .const import (
KNX_MODULE_KEY, KNX_MODULE_KEY,
) )
from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity
from .storage.const import CONF_ENTITY, CONF_GA_PASSIVE, CONF_GA_SENSOR, CONF_GA_STATE from .storage.const import CONF_ENTITY, CONF_GA_SENSOR
from .storage.util import ConfigExtractor
async def async_setup_entry( async def async_setup_entry(
@ -146,17 +147,17 @@ class KnxUiBinarySensor(_KnxBinarySensor, KnxUiEntity):
unique_id=unique_id, unique_id=unique_id,
entity_config=config[CONF_ENTITY], entity_config=config[CONF_ENTITY],
) )
knx_conf = ConfigExtractor(config[DOMAIN])
self._device = XknxBinarySensor( self._device = XknxBinarySensor(
xknx=knx_module.xknx, xknx=knx_module.xknx,
name=config[CONF_ENTITY][CONF_NAME], name=config[CONF_ENTITY][CONF_NAME],
group_address_state=[ group_address_state=knx_conf.get_state_and_passive(CONF_GA_SENSOR),
config[DOMAIN][CONF_GA_SENSOR][CONF_GA_STATE], sync_state=knx_conf.get(CONF_SYNC_STATE),
*config[DOMAIN][CONF_GA_SENSOR][CONF_GA_PASSIVE], invert=knx_conf.get(CONF_INVERT, default=False),
], ignore_internal_state=knx_conf.get(
sync_state=config[DOMAIN][CONF_SYNC_STATE], CONF_IGNORE_INTERNAL_STATE, default=False
invert=config[DOMAIN].get(CONF_INVERT, False), ),
ignore_internal_state=config[DOMAIN].get(CONF_IGNORE_INTERNAL_STATE, False), context_timeout=knx_conf.get(CONF_CONTEXT_TIMEOUT),
context_timeout=config[DOMAIN].get(CONF_CONTEXT_TIMEOUT), reset_after=knx_conf.get(CONF_RESET_AFTER),
reset_after=config[DOMAIN].get(CONF_RESET_AFTER),
) )
self._attr_force_update = self._device.ignore_internal_state self._attr_force_update = self._device.ignore_internal_state

View File

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Literal from typing import Any
from xknx import XKNX from xknx import XKNX
from xknx.devices import Cover as XknxCover from xknx.devices import Cover as XknxCover
@ -35,15 +35,13 @@ from .schema import CoverSchema
from .storage.const import ( from .storage.const import (
CONF_ENTITY, CONF_ENTITY,
CONF_GA_ANGLE, CONF_GA_ANGLE,
CONF_GA_PASSIVE,
CONF_GA_POSITION_SET, CONF_GA_POSITION_SET,
CONF_GA_POSITION_STATE, CONF_GA_POSITION_STATE,
CONF_GA_STATE,
CONF_GA_STEP, CONF_GA_STEP,
CONF_GA_STOP, CONF_GA_STOP,
CONF_GA_UP_DOWN, CONF_GA_UP_DOWN,
CONF_GA_WRITE,
) )
from .storage.util import ConfigExtractor
async def async_setup_entry( async def async_setup_entry(
@ -230,38 +228,24 @@ class KnxYamlCover(_KnxCover, KnxYamlEntity):
def _create_ui_cover(xknx: XKNX, knx_config: ConfigType, name: str) -> XknxCover: def _create_ui_cover(xknx: XKNX, knx_config: ConfigType, name: str) -> XknxCover:
"""Return a KNX Light device to be used within XKNX.""" """Return a KNX Light device to be used within XKNX."""
def get_address( conf = ConfigExtractor(knx_config)
key: str, address_type: Literal["write", "state"] = CONF_GA_WRITE
) -> str | None:
"""Get a single group address for given key."""
return knx_config[key][address_type] if key in knx_config else None
def get_addresses(
key: str, address_type: Literal["write", "state"] = CONF_GA_STATE
) -> list[Any] | None:
"""Get group address including passive addresses as list."""
return (
[knx_config[key][address_type], *knx_config[key][CONF_GA_PASSIVE]]
if key in knx_config
else None
)
return XknxCover( return XknxCover(
xknx=xknx, xknx=xknx,
name=name, name=name,
group_address_long=get_addresses(CONF_GA_UP_DOWN, CONF_GA_WRITE), group_address_long=conf.get_write_and_passive(CONF_GA_UP_DOWN),
group_address_short=get_addresses(CONF_GA_STEP, CONF_GA_WRITE), group_address_short=conf.get_write_and_passive(CONF_GA_STEP),
group_address_stop=get_addresses(CONF_GA_STOP, CONF_GA_WRITE), group_address_stop=conf.get_write_and_passive(CONF_GA_STOP),
group_address_position=get_addresses(CONF_GA_POSITION_SET, CONF_GA_WRITE), group_address_position=conf.get_write_and_passive(CONF_GA_POSITION_SET),
group_address_position_state=get_addresses(CONF_GA_POSITION_STATE), group_address_position_state=conf.get_state_and_passive(CONF_GA_POSITION_STATE),
group_address_angle=get_address(CONF_GA_ANGLE), group_address_angle=conf.get_write(CONF_GA_ANGLE),
group_address_angle_state=get_addresses(CONF_GA_ANGLE), group_address_angle_state=conf.get_state_and_passive(CONF_GA_ANGLE),
travel_time_down=knx_config[CoverConf.TRAVELLING_TIME_DOWN], travel_time_down=conf.get(CoverConf.TRAVELLING_TIME_DOWN),
travel_time_up=knx_config[CoverConf.TRAVELLING_TIME_UP], travel_time_up=conf.get(CoverConf.TRAVELLING_TIME_UP),
invert_updown=knx_config.get(CoverConf.INVERT_UPDOWN, False), invert_updown=conf.get(CoverConf.INVERT_UPDOWN, default=False),
invert_position=knx_config.get(CoverConf.INVERT_POSITION, False), invert_position=conf.get(CoverConf.INVERT_POSITION, default=False),
invert_angle=knx_config.get(CoverConf.INVERT_ANGLE, False), invert_angle=conf.get(CoverConf.INVERT_ANGLE, default=False),
sync_state=knx_config[CONF_SYNC_STATE], sync_state=conf.get(CONF_SYNC_STATE),
) )

View File

@ -35,7 +35,6 @@ from .schema import LightSchema
from .storage.const import ( from .storage.const import (
CONF_COLOR_TEMP_MAX, CONF_COLOR_TEMP_MAX,
CONF_COLOR_TEMP_MIN, CONF_COLOR_TEMP_MIN,
CONF_DPT,
CONF_ENTITY, CONF_ENTITY,
CONF_GA_BLUE_BRIGHTNESS, CONF_GA_BLUE_BRIGHTNESS,
CONF_GA_BLUE_SWITCH, CONF_GA_BLUE_SWITCH,
@ -45,17 +44,15 @@ from .storage.const import (
CONF_GA_GREEN_BRIGHTNESS, CONF_GA_GREEN_BRIGHTNESS,
CONF_GA_GREEN_SWITCH, CONF_GA_GREEN_SWITCH,
CONF_GA_HUE, CONF_GA_HUE,
CONF_GA_PASSIVE,
CONF_GA_RED_BRIGHTNESS, CONF_GA_RED_BRIGHTNESS,
CONF_GA_RED_SWITCH, CONF_GA_RED_SWITCH,
CONF_GA_SATURATION, CONF_GA_SATURATION,
CONF_GA_STATE,
CONF_GA_SWITCH, CONF_GA_SWITCH,
CONF_GA_WHITE_BRIGHTNESS, CONF_GA_WHITE_BRIGHTNESS,
CONF_GA_WHITE_SWITCH, CONF_GA_WHITE_SWITCH,
CONF_GA_WRITE,
) )
from .storage.entity_store_schema import LightColorMode from .storage.entity_store_schema import LightColorMode
from .storage.util import ConfigExtractor
async def async_setup_entry( async def async_setup_entry(
@ -203,94 +200,92 @@ def _create_yaml_light(xknx: XKNX, config: ConfigType) -> XknxLight:
def _create_ui_light(xknx: XKNX, knx_config: ConfigType, name: str) -> XknxLight: def _create_ui_light(xknx: XKNX, knx_config: ConfigType, name: str) -> XknxLight:
"""Return a KNX Light device to be used within XKNX.""" """Return a KNX Light device to be used within XKNX."""
def get_write(key: str) -> str | None: conf = ConfigExtractor(knx_config)
"""Get the write group address."""
return knx_config[key][CONF_GA_WRITE] if key in knx_config else None
def get_state(key: str) -> list[Any] | None:
"""Get the state group address."""
return (
[knx_config[key][CONF_GA_STATE], *knx_config[key][CONF_GA_PASSIVE]]
if key in knx_config
else None
)
def get_dpt(key: str) -> str | None:
"""Get the DPT."""
return knx_config[key].get(CONF_DPT) if key in knx_config else None
group_address_tunable_white = None group_address_tunable_white = None
group_address_tunable_white_state = None group_address_tunable_white_state = None
group_address_color_temp = None group_address_color_temp = None
group_address_color_temp_state = None group_address_color_temp_state = None
color_temperature_type = ColorTemperatureType.UINT_2_BYTE color_temperature_type = ColorTemperatureType.UINT_2_BYTE
if ga_color_temp := knx_config.get(CONF_GA_COLOR_TEMP): if _color_temp_dpt := conf.get_dpt(CONF_GA_COLOR_TEMP):
if ga_color_temp[CONF_DPT] == ColorTempModes.RELATIVE.value: if _color_temp_dpt == ColorTempModes.RELATIVE.value:
group_address_tunable_white = ga_color_temp[CONF_GA_WRITE] group_address_tunable_white = conf.get_write(CONF_GA_COLOR_TEMP)
group_address_tunable_white_state = [ group_address_tunable_white_state = conf.get_state_and_passive(
ga_color_temp[CONF_GA_STATE], CONF_GA_COLOR_TEMP
*ga_color_temp[CONF_GA_PASSIVE], )
]
else: else:
# absolute uint or float # absolute uint or float
group_address_color_temp = ga_color_temp[CONF_GA_WRITE] group_address_color_temp = conf.get_write(CONF_GA_COLOR_TEMP)
group_address_color_temp_state = [ group_address_color_temp_state = conf.get_state_and_passive(
ga_color_temp[CONF_GA_STATE], CONF_GA_COLOR_TEMP
*ga_color_temp[CONF_GA_PASSIVE], )
] if _color_temp_dpt == ColorTempModes.ABSOLUTE_FLOAT.value:
if ga_color_temp[CONF_DPT] == ColorTempModes.ABSOLUTE_FLOAT.value:
color_temperature_type = ColorTemperatureType.FLOAT_2_BYTE color_temperature_type = ColorTemperatureType.FLOAT_2_BYTE
_color_dpt = get_dpt(CONF_GA_COLOR) color_dpt = conf.get_dpt(CONF_GA_COLOR)
return XknxLight( return XknxLight(
xknx, xknx,
name=name, name=name,
group_address_switch=get_write(CONF_GA_SWITCH), group_address_switch=conf.get_write(CONF_GA_SWITCH),
group_address_switch_state=get_state(CONF_GA_SWITCH), group_address_switch_state=conf.get_state_and_passive(CONF_GA_SWITCH),
group_address_brightness=get_write(CONF_GA_BRIGHTNESS), group_address_brightness=conf.get_write(CONF_GA_BRIGHTNESS),
group_address_brightness_state=get_state(CONF_GA_BRIGHTNESS), group_address_brightness_state=conf.get_state_and_passive(CONF_GA_BRIGHTNESS),
group_address_color=get_write(CONF_GA_COLOR) group_address_color=conf.get_write(CONF_GA_COLOR)
if _color_dpt == LightColorMode.RGB if color_dpt == LightColorMode.RGB
else None, else None,
group_address_color_state=get_state(CONF_GA_COLOR) group_address_color_state=conf.get_state_and_passive(CONF_GA_COLOR)
if _color_dpt == LightColorMode.RGB if color_dpt == LightColorMode.RGB
else None, else None,
group_address_rgbw=get_write(CONF_GA_COLOR) group_address_rgbw=conf.get_write(CONF_GA_COLOR)
if _color_dpt == LightColorMode.RGBW if color_dpt == LightColorMode.RGBW
else None, else None,
group_address_rgbw_state=get_state(CONF_GA_COLOR) group_address_rgbw_state=conf.get_state_and_passive(CONF_GA_COLOR)
if _color_dpt == LightColorMode.RGBW if color_dpt == LightColorMode.RGBW
else None, else None,
group_address_hue=get_write(CONF_GA_HUE), group_address_hue=conf.get_write(CONF_GA_HUE),
group_address_hue_state=get_state(CONF_GA_HUE), group_address_hue_state=conf.get_state_and_passive(CONF_GA_HUE),
group_address_saturation=get_write(CONF_GA_SATURATION), group_address_saturation=conf.get_write(CONF_GA_SATURATION),
group_address_saturation_state=get_state(CONF_GA_SATURATION), group_address_saturation_state=conf.get_state_and_passive(CONF_GA_SATURATION),
group_address_xyy_color=get_write(CONF_GA_COLOR) group_address_xyy_color=conf.get_write(CONF_GA_COLOR)
if _color_dpt == LightColorMode.XYY if color_dpt == LightColorMode.XYY
else None, else None,
group_address_xyy_color_state=get_write(CONF_GA_COLOR) group_address_xyy_color_state=conf.get_write(CONF_GA_COLOR)
if _color_dpt == LightColorMode.XYY if color_dpt == LightColorMode.XYY
else None, else None,
group_address_tunable_white=group_address_tunable_white, group_address_tunable_white=group_address_tunable_white,
group_address_tunable_white_state=group_address_tunable_white_state, group_address_tunable_white_state=group_address_tunable_white_state,
group_address_color_temperature=group_address_color_temp, group_address_color_temperature=group_address_color_temp,
group_address_color_temperature_state=group_address_color_temp_state, group_address_color_temperature_state=group_address_color_temp_state,
group_address_switch_red=get_write(CONF_GA_RED_SWITCH), group_address_switch_red=conf.get_write(CONF_GA_RED_SWITCH),
group_address_switch_red_state=get_state(CONF_GA_RED_SWITCH), group_address_switch_red_state=conf.get_state_and_passive(CONF_GA_RED_SWITCH),
group_address_brightness_red=get_write(CONF_GA_RED_BRIGHTNESS), group_address_brightness_red=conf.get_write(CONF_GA_RED_BRIGHTNESS),
group_address_brightness_red_state=get_state(CONF_GA_RED_BRIGHTNESS), group_address_brightness_red_state=conf.get_state_and_passive(
group_address_switch_green=get_write(CONF_GA_GREEN_SWITCH), CONF_GA_RED_BRIGHTNESS
group_address_switch_green_state=get_state(CONF_GA_GREEN_SWITCH), ),
group_address_brightness_green=get_write(CONF_GA_GREEN_BRIGHTNESS), group_address_switch_green=conf.get_write(CONF_GA_GREEN_SWITCH),
group_address_brightness_green_state=get_state(CONF_GA_GREEN_BRIGHTNESS), group_address_switch_green_state=conf.get_state_and_passive(
group_address_switch_blue=get_write(CONF_GA_BLUE_SWITCH), CONF_GA_GREEN_SWITCH
group_address_switch_blue_state=get_state(CONF_GA_BLUE_SWITCH), ),
group_address_brightness_blue=get_write(CONF_GA_BLUE_BRIGHTNESS), group_address_brightness_green=conf.get_write(CONF_GA_GREEN_BRIGHTNESS),
group_address_brightness_blue_state=get_state(CONF_GA_BLUE_BRIGHTNESS), group_address_brightness_green_state=conf.get_state_and_passive(
group_address_switch_white=get_write(CONF_GA_WHITE_SWITCH), CONF_GA_GREEN_BRIGHTNESS
group_address_switch_white_state=get_state(CONF_GA_WHITE_SWITCH), ),
group_address_brightness_white=get_write(CONF_GA_WHITE_BRIGHTNESS), group_address_switch_blue=conf.get_write(CONF_GA_BLUE_SWITCH),
group_address_brightness_white_state=get_state(CONF_GA_WHITE_BRIGHTNESS), group_address_switch_blue_state=conf.get_state_and_passive(CONF_GA_BLUE_SWITCH),
group_address_brightness_blue=conf.get_write(CONF_GA_BLUE_BRIGHTNESS),
group_address_brightness_blue_state=conf.get_state_and_passive(
CONF_GA_BLUE_BRIGHTNESS
),
group_address_switch_white=conf.get_write(CONF_GA_WHITE_SWITCH),
group_address_switch_white_state=conf.get_state_and_passive(
CONF_GA_WHITE_SWITCH
),
group_address_brightness_white=conf.get_write(CONF_GA_WHITE_BRIGHTNESS),
group_address_brightness_white_state=conf.get_state_and_passive(
CONF_GA_WHITE_BRIGHTNESS
),
color_temperature_type=color_temperature_type, color_temperature_type=color_temperature_type,
min_kelvin=knx_config[CONF_COLOR_TEMP_MIN], min_kelvin=knx_config[CONF_COLOR_TEMP_MIN],
max_kelvin=knx_config[CONF_COLOR_TEMP_MAX], max_kelvin=knx_config[CONF_COLOR_TEMP_MAX],

View File

@ -41,7 +41,7 @@ _LOGGER = logging.getLogger(__name__)
@callback @callback
def register_knx_services(hass: HomeAssistant) -> None: def async_setup_services(hass: HomeAssistant) -> None:
"""Register KNX integration services.""" """Register KNX integration services."""
hass.services.async_register( hass.services.async_register(
DOMAIN, DOMAIN,

View File

@ -0,0 +1,51 @@
"""Utility functions for the KNX integration."""
from functools import partial
from typing import Any
from homeassistant.helpers.typing import ConfigType
from .const import CONF_DPT, CONF_GA_PASSIVE, CONF_GA_STATE, CONF_GA_WRITE
def nested_get(dic: ConfigType, *keys: str, default: Any | None = None) -> Any:
"""Get the value from a nested dictionary."""
for key in keys:
if key not in dic:
return default
dic = dic[key]
return dic
class ConfigExtractor:
"""Helper class for extracting values from a knx config store dictionary."""
__slots__ = ("get",)
def __init__(self, config: ConfigType) -> None:
"""Initialize the extractor."""
self.get = partial(nested_get, config)
def get_write(self, *path: str) -> str | None:
"""Get the write group address."""
return self.get(*path, CONF_GA_WRITE) # type: ignore[no-any-return]
def get_state(self, *path: str) -> str | None:
"""Get the state group address."""
return self.get(*path, CONF_GA_STATE) # type: ignore[no-any-return]
def get_write_and_passive(self, *path: str) -> list[Any | None]:
"""Get the group addresses of write and passive."""
write = self.get(*path, CONF_GA_WRITE)
passive = self.get(*path, CONF_GA_PASSIVE)
return [write, *passive] if passive else [write]
def get_state_and_passive(self, *path: str) -> list[Any | None]:
"""Get the group addresses of state and passive."""
state = self.get(*path, CONF_GA_STATE)
passive = self.get(*path, CONF_GA_PASSIVE)
return [state, *passive] if passive else [state]
def get_dpt(self, *path: str) -> str | None:
"""Get the data point type of a group address config key."""
return self.get(*path, CONF_DPT) # type: ignore[no-any-return]

View File

@ -36,13 +36,8 @@ from .const import (
) )
from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity
from .schema import SwitchSchema from .schema import SwitchSchema
from .storage.const import ( from .storage.const import CONF_ENTITY, CONF_GA_SWITCH
CONF_ENTITY, from .storage.util import ConfigExtractor
CONF_GA_PASSIVE,
CONF_GA_STATE,
CONF_GA_SWITCH,
CONF_GA_WRITE,
)
async def async_setup_entry( async def async_setup_entry(
@ -142,15 +137,13 @@ class KnxUiSwitch(_KnxSwitch, KnxUiEntity):
unique_id=unique_id, unique_id=unique_id,
entity_config=config[CONF_ENTITY], entity_config=config[CONF_ENTITY],
) )
knx_conf = ConfigExtractor(config[DOMAIN])
self._device = XknxSwitch( self._device = XknxSwitch(
knx_module.xknx, knx_module.xknx,
name=config[CONF_ENTITY][CONF_NAME], name=config[CONF_ENTITY][CONF_NAME],
group_address=config[DOMAIN][CONF_GA_SWITCH][CONF_GA_WRITE], group_address=knx_conf.get_write(CONF_GA_SWITCH),
group_address_state=[ group_address_state=knx_conf.get_state_and_passive(CONF_GA_SWITCH),
config[DOMAIN][CONF_GA_SWITCH][CONF_GA_STATE], respond_to_read=knx_conf.get(CONF_RESPOND_TO_READ),
*config[DOMAIN][CONF_GA_SWITCH][CONF_GA_PASSIVE], sync_state=knx_conf.get(CONF_SYNC_STATE),
], invert=knx_conf.get(CONF_INVERT),
respond_to_read=config[DOMAIN][CONF_RESPOND_TO_READ],
sync_state=config[DOMAIN][CONF_SYNC_STATE],
invert=config[DOMAIN][CONF_INVERT],
) )

View File

@ -104,7 +104,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: LcnConfigEntry) -
) as ex: ) as ex:
await lcn_connection.async_close() await lcn_connection.async_close()
raise ConfigEntryNotReady( raise ConfigEntryNotReady(
f"Unable to connect to {config_entry.title}: {ex}" translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={
"config_entry_title": config_entry.title,
},
) from ex ) from ex
_LOGGER.info('LCN connected to "%s"', config_entry.title) _LOGGER.info('LCN connected to "%s"', config_entry.title)

View File

@ -26,6 +26,7 @@ from homeassistant.const import (
CONF_SWITCHES, CONF_SWITCHES,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
@ -100,7 +101,11 @@ def get_resource(domain_name: str, domain_data: ConfigType) -> str:
return cast(str, domain_data["setpoint"]) return cast(str, domain_data["setpoint"])
if domain_name == "scene": if domain_name == "scene":
return f"{domain_data['register']}{domain_data['scene']}" return f"{domain_data['register']}{domain_data['scene']}"
raise ValueError("Unknown domain") raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="invalid_domain",
translation_placeholders={CONF_DOMAIN: domain_name},
)
def generate_unique_id( def generate_unique_id(
@ -304,6 +309,8 @@ def get_device_config(
def is_states_string(states_string: str) -> list[str]: def is_states_string(states_string: str) -> list[str]:
"""Validate the given states string and return states list.""" """Validate the given states string and return states list."""
if len(states_string) != 8: if len(states_string) != 8:
raise ValueError("Invalid length of states string") raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="invalid_length_of_states_string"
)
states = {"1": "ON", "0": "OFF", "T": "TOGGLE", "-": "NOCHANGE"} states = {"1": "ON", "0": "OFF", "T": "TOGGLE", "-": "NOCHANGE"}
return [states[state_string] for state_string in states_string] return [states[state_string] for state_string in states_string]

View File

@ -19,7 +19,7 @@ rules:
test-before-setup: done test-before-setup: done
unique-config-entry: done unique-config-entry: done
# Silver # Silver
action-exceptions: todo action-exceptions: done
config-entry-unloading: done config-entry-unloading: done
docs-configuration-parameters: docs-configuration-parameters:
status: exempt status: exempt

View File

@ -330,8 +330,9 @@ class SendKeys(LcnServiceCall):
if (delay_time := service.data[CONF_TIME]) != 0: if (delay_time := service.data[CONF_TIME]) != 0:
hit = pypck.lcn_defs.SendKeyCommand.HIT hit = pypck.lcn_defs.SendKeyCommand.HIT
if pypck.lcn_defs.SendKeyCommand[service.data[CONF_STATE]] != hit: if pypck.lcn_defs.SendKeyCommand[service.data[CONF_STATE]] != hit:
raise ValueError( raise ServiceValidationError(
"Only hit command is allowed when sending deferred keys." translation_domain=DOMAIN,
translation_key="invalid_send_keys_action",
) )
delay_unit = pypck.lcn_defs.TimeUnit.parse(service.data[CONF_TIME_UNIT]) delay_unit = pypck.lcn_defs.TimeUnit.parse(service.data[CONF_TIME_UNIT])
await device_connection.send_keys_hit_deferred(keys, delay_time, delay_unit) await device_connection.send_keys_hit_deferred(keys, delay_time, delay_unit)
@ -368,8 +369,9 @@ class LockKeys(LcnServiceCall):
if (delay_time := service.data[CONF_TIME]) != 0: if (delay_time := service.data[CONF_TIME]) != 0:
if table_id != 0: if table_id != 0:
raise ValueError( raise ServiceValidationError(
"Only table A is allowed when locking keys for a specific time." translation_domain=DOMAIN,
translation_key="invalid_lock_keys_table",
) )
delay_unit = pypck.lcn_defs.TimeUnit.parse(service.data[CONF_TIME_UNIT]) delay_unit = pypck.lcn_defs.TimeUnit.parse(service.data[CONF_TIME_UNIT])
await device_connection.lock_keys_tab_a_temporary( await device_connection.lock_keys_tab_a_temporary(

View File

@ -414,11 +414,23 @@
} }
}, },
"exceptions": { "exceptions": {
"invalid_address": { "cannot_connect": {
"message": "LCN device for given address has not been configured." "message": "Unable to connect to {config_entry_title}."
}, },
"invalid_device_id": { "invalid_device_id": {
"message": "LCN device for given device ID has not been configured." "message": "LCN device for given device ID {device_id} has not been configured."
},
"invalid_domain": {
"message": "Invalid domain {domain}."
},
"invalid_send_keys_action": {
"message": "Invalid state for sending keys. Only 'hit' allowed for deferred sending."
},
"invalid_lock_keys_table": {
"message": "Invalid table for locking keys. Only table A allowed when locking for a specific time."
},
"invalid_length_of_states_string": {
"message": "Invalid length of states string. Expected 8 characters."
} }
} }
} }

View File

@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "documentation": "https://www.home-assistant.io/integrations/ld2410_ble",
"integration_type": "device", "integration_type": "device",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["bluetooth-data-tools==1.28.1", "ld2410-ble==0.1.1"] "requirements": ["bluetooth-data-tools==1.28.2", "ld2410-ble==0.1.1"]
} }

View File

@ -35,5 +35,5 @@
"dependencies": ["bluetooth_adapters"], "dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/led_ble", "documentation": "https://www.home-assistant.io/integrations/led_ble",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["bluetooth-data-tools==1.28.1", "led-ble==1.1.7"] "requirements": ["bluetooth-data-tools==1.28.2", "led-ble==1.1.7"]
} }

View File

@ -780,10 +780,10 @@
"battery_level": { "battery_level": {
"name": "Battery", "name": "Battery",
"state": { "state": {
"high": "Full", "high": "[%key:common::state::full%]",
"mid": "[%key:common::state::medium%]", "mid": "[%key:common::state::medium%]",
"low": "[%key:common::state::low%]", "low": "[%key:common::state::low%]",
"warning": "Empty" "warning": "[%key:common::state::empty%]"
} }
}, },
"relative_to_start": { "relative_to_start": {

View File

@ -70,7 +70,7 @@
"motor_fault_short": "Motor shorted", "motor_fault_short": "Motor shorted",
"motor_ot_amps": "Motor overtorqued", "motor_ot_amps": "Motor overtorqued",
"motor_disconnected": "Motor disconnected", "motor_disconnected": "Motor disconnected",
"empty": "Empty" "empty": "[%key:common::state::empty%]"
} }
}, },
"last_seen": { "last_seen": {

View File

@ -200,7 +200,7 @@ async def async_remove_config_entry_device(
hass: HomeAssistant, entry: LookinConfigEntry, device_entry: dr.DeviceEntry hass: HomeAssistant, entry: LookinConfigEntry, device_entry: dr.DeviceEntry
) -> bool: ) -> bool:
"""Remove lookin config entry from a device.""" """Remove lookin config entry from a device."""
data: LookinData = hass.data[DOMAIN][entry.entry_id] data = entry.runtime_data
all_identifiers: set[tuple[str, str]] = { all_identifiers: set[tuple[str, str]] = {
(DOMAIN, data.lookin_device.id), (DOMAIN, data.lookin_device.id),
*((DOMAIN, remote["UUID"]) for remote in data.devices), *((DOMAIN, remote["UUID"]) for remote in data.devices),

View File

@ -45,7 +45,7 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.util.json import JsonObjectType, load_json_object from homeassistant.util.json import JsonObjectType, load_json_object
from .const import ATTR_FORMAT, ATTR_IMAGES, CONF_ROOMS_REGEX, DOMAIN, FORMAT_HTML from .const import ATTR_FORMAT, ATTR_IMAGES, CONF_ROOMS_REGEX, DOMAIN, FORMAT_HTML
from .services import register_services from .services import async_setup_services
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -128,7 +128,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
config[CONF_COMMANDS], config[CONF_COMMANDS],
) )
register_services(hass) async_setup_services(hass)
return True return True

View File

@ -6,5 +6,5 @@
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["matrix_client"], "loggers": ["matrix_client"],
"quality_scale": "legacy", "quality_scale": "legacy",
"requirements": ["matrix-nio==0.25.2", "Pillow==11.2.1"] "requirements": ["matrix-nio==0.25.2", "Pillow==11.3.0"]
} }

View File

@ -7,7 +7,7 @@ from typing import TYPE_CHECKING
import voluptuous as vol import voluptuous as vol
from homeassistant.components.notify import ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET from homeassistant.components.notify import ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET
from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from .const import ( from .const import (
@ -50,7 +50,8 @@ async def _handle_send_message(call: ServiceCall) -> None:
await matrix_bot.handle_send_message(call) await matrix_bot.handle_send_message(call)
def register_services(hass: HomeAssistant) -> None: @callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up the Matrix bot component.""" """Set up the Matrix bot component."""
hass.services.async_register( hass.services.async_register(

View File

@ -7,6 +7,6 @@
"dependencies": ["websocket_api"], "dependencies": ["websocket_api"],
"documentation": "https://www.home-assistant.io/integrations/matter", "documentation": "https://www.home-assistant.io/integrations/matter",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["python-matter-server==7.0.0"], "requirements": ["python-matter-server==8.0.0"],
"zeroconf": ["_matter._tcp.local.", "_matterc._udp.local."] "zeroconf": ["_matter._tcp.local.", "_matterc._udp.local."]
} }

Some files were not shown because too many files have changed in this diff Show More