mirror of
https://github.com/home-assistant/core.git
synced 2025-10-15 14:49:34 +00:00
Compare commits
100 Commits
chat-log-s
...
master
Author | SHA1 | Date | |
---|---|---|---|
![]() |
40d7f2a89e | ||
![]() |
13b717e2da | ||
![]() |
5fcfd3ad84 | ||
![]() |
324a7b5443 | ||
![]() |
491ae8f72c | ||
![]() |
259247892f | ||
![]() |
caeda0ef64 | ||
![]() |
df35c535e4 | ||
![]() |
f93b9e0ed0 | ||
![]() |
48a3372cf2 | ||
![]() |
d84fd72428 | ||
![]() |
e8cb386962 | ||
![]() |
5ac726703c | ||
![]() |
688649a799 | ||
![]() |
c5359ade3e | ||
![]() |
4e60dedc1b | ||
![]() |
221d74f83a | ||
![]() |
fbbb3d6415 | ||
![]() |
8297019011 | ||
![]() |
61715dcff3 | ||
![]() |
32b822ee99 | ||
![]() |
e6c2e0ad80 | ||
![]() |
1314427dc5 | ||
![]() |
bf499a45f7 | ||
![]() |
b955e22628 | ||
![]() |
1b222ff5fd | ||
![]() |
f0510e703f | ||
![]() |
cbe3956e15 | ||
![]() |
4588e9da8d | ||
![]() |
5445890fdf | ||
![]() |
9b49f77f86 | ||
![]() |
566c8fb786 | ||
![]() |
b36150c213 | ||
![]() |
809070d2ad | ||
![]() |
f4339dc031 | ||
![]() |
f3b37d24b0 | ||
![]() |
4c8348caa7 | ||
![]() |
b9e7c102ea | ||
![]() |
69d9fa89b7 | ||
![]() |
6f3f5a5ec1 | ||
![]() |
5ecfeca90a | ||
![]() |
00e0570fd4 | ||
![]() |
5a5b94f3af | ||
![]() |
34f00d9b33 | ||
![]() |
4cabc5b368 | ||
![]() |
4045125422 | ||
![]() |
d7393af76f | ||
![]() |
ad41386b27 | ||
![]() |
62d17ea20c | ||
![]() |
c4954731d0 | ||
![]() |
647723d3f0 | ||
![]() |
51c500e22c | ||
![]() |
f6fc13c1f2 | ||
![]() |
0009a7a042 | ||
![]() |
a3d1aa28e7 | ||
![]() |
9f53eb9b76 | ||
![]() |
f53a205ff3 | ||
![]() |
d08517c3df | ||
![]() |
d7398a44a1 | ||
![]() |
9acfc0cb88 | ||
![]() |
1b3d21523a | ||
![]() |
1d407d1326 | ||
![]() |
013346cead | ||
![]() |
5abaabc9da | ||
![]() |
32481312c3 | ||
![]() |
bdc9eb37d3 | ||
![]() |
e0afcbc02b | ||
![]() |
cd56a6a98d | ||
![]() |
9d85893bbb | ||
![]() |
9e8a70225f | ||
![]() |
96ec795d5e | ||
![]() |
65b796070d | ||
![]() |
32994812e5 | ||
![]() |
66ff9d63a3 | ||
![]() |
b2a63d4996 | ||
![]() |
f9f37b7f2a | ||
![]() |
7bdd9dd38a | ||
![]() |
1e8aae0a89 | ||
![]() |
cf668e9dc2 | ||
![]() |
2e91c8700f | ||
![]() |
9d14627daa | ||
![]() |
73b8283748 | ||
![]() |
edeaaa2e63 | ||
![]() |
d26dd8fc39 | ||
![]() |
34640ea735 | ||
![]() |
46a2e21ef0 | ||
![]() |
508af53e72 | ||
![]() |
5f7440608c | ||
![]() |
0d1aa38a26 | ||
![]() |
929f8c148a | ||
![]() |
92db1f5a04 | ||
![]() |
e66b5ce0bf | ||
![]() |
1e17150e9f | ||
![]() |
792902de3d | ||
![]() |
04d78c3dd5 | ||
![]() |
5c8d5bfb84 | ||
![]() |
99bff31869 | ||
![]() |
d949119fb0 | ||
![]() |
e7b737ece5 | ||
![]() |
fb8ddac2e8 |
10
.github/workflows/builder.yml
vendored
10
.github/workflows/builder.yml
vendored
@@ -190,7 +190,7 @@ jobs:
|
||||
echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -257,7 +257,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -332,14 +332,14 @@ jobs:
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: matrix.registry == 'ghcr.io/home-assistant'
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -504,7 +504,7 @@ jobs:
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
|
720
.github/workflows/ci.yaml
vendored
720
.github/workflows/ci.yaml
vendored
File diff suppressed because it is too large
Load Diff
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
||||
uses: github/codeql-action/init@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
||||
uses: github/codeql-action/analyze@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
6
.github/workflows/stale.yml
vendored
6
.github/workflows/stale.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
# - No PRs marked as no-stale
|
||||
# - No issues (-1)
|
||||
- name: 60 days stale PRs policy
|
||||
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 60
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
# - No issues marked as no-stale or help-wanted
|
||||
# - No PRs (-1)
|
||||
- name: 90 days stale issues
|
||||
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
days-before-stale: 90
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
# - No Issues marked as no-stale or help-wanted
|
||||
# - No PRs (-1)
|
||||
- name: Needs more information stale issues policy
|
||||
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
only-labels: "needs-more-information"
|
||||
|
@@ -203,7 +203,6 @@ homeassistant.components.feedreader.*
|
||||
homeassistant.components.file_upload.*
|
||||
homeassistant.components.filesize.*
|
||||
homeassistant.components.filter.*
|
||||
homeassistant.components.firefly_iii.*
|
||||
homeassistant.components.fitbit.*
|
||||
homeassistant.components.flexit_bacnet.*
|
||||
homeassistant.components.flux_led.*
|
||||
@@ -221,7 +220,6 @@ homeassistant.components.generic_thermostat.*
|
||||
homeassistant.components.geo_location.*
|
||||
homeassistant.components.geocaching.*
|
||||
homeassistant.components.gios.*
|
||||
homeassistant.components.github.*
|
||||
homeassistant.components.glances.*
|
||||
homeassistant.components.go2rtc.*
|
||||
homeassistant.components.goalzero.*
|
||||
@@ -327,7 +325,6 @@ homeassistant.components.london_underground.*
|
||||
homeassistant.components.lookin.*
|
||||
homeassistant.components.lovelace.*
|
||||
homeassistant.components.luftdaten.*
|
||||
homeassistant.components.lunatone.*
|
||||
homeassistant.components.madvr.*
|
||||
homeassistant.components.manual.*
|
||||
homeassistant.components.mastodon.*
|
||||
@@ -556,7 +553,6 @@ homeassistant.components.vacuum.*
|
||||
homeassistant.components.vallox.*
|
||||
homeassistant.components.valve.*
|
||||
homeassistant.components.velbus.*
|
||||
homeassistant.components.vivotek.*
|
||||
homeassistant.components.vlc_telnet.*
|
||||
homeassistant.components.vodafone_station.*
|
||||
homeassistant.components.volvo.*
|
||||
|
18
CODEOWNERS
generated
18
CODEOWNERS
generated
@@ -492,8 +492,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/filesize/ @gjohansson-ST
|
||||
/homeassistant/components/filter/ @dgomes
|
||||
/tests/components/filter/ @dgomes
|
||||
/homeassistant/components/firefly_iii/ @erwindouna
|
||||
/tests/components/firefly_iii/ @erwindouna
|
||||
/homeassistant/components/fireservicerota/ @cyberjunky
|
||||
/tests/components/fireservicerota/ @cyberjunky
|
||||
/homeassistant/components/firmata/ @DaAwesomeP
|
||||
@@ -910,8 +908,6 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/luci/ @mzdrale
|
||||
/homeassistant/components/luftdaten/ @fabaff @frenck
|
||||
/tests/components/luftdaten/ @fabaff @frenck
|
||||
/homeassistant/components/lunatone/ @MoonDevLT
|
||||
/tests/components/lunatone/ @MoonDevLT
|
||||
/homeassistant/components/lupusec/ @majuss @suaveolent
|
||||
/tests/components/lupusec/ @majuss @suaveolent
|
||||
/homeassistant/components/lutron/ @cdheiser @wilburCForce
|
||||
@@ -957,8 +953,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/met_eireann/ @DylanGore
|
||||
/homeassistant/components/meteo_france/ @hacf-fr @oncleben31 @Quentame
|
||||
/tests/components/meteo_france/ @hacf-fr @oncleben31 @Quentame
|
||||
/homeassistant/components/meteo_lt/ @xE1H
|
||||
/tests/components/meteo_lt/ @xE1H
|
||||
/homeassistant/components/meteoalarm/ @rolfberkenbosch
|
||||
/homeassistant/components/meteoclimatic/ @adrianmo
|
||||
/tests/components/meteoclimatic/ @adrianmo
|
||||
@@ -1065,8 +1059,6 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/nilu/ @hfurubotten
|
||||
/homeassistant/components/nina/ @DeerMaximum
|
||||
/tests/components/nina/ @DeerMaximum
|
||||
/homeassistant/components/nintendo_parental_controls/ @pantherale0
|
||||
/tests/components/nintendo_parental_controls/ @pantherale0
|
||||
/homeassistant/components/nissan_leaf/ @filcole
|
||||
/homeassistant/components/noaa_tides/ @jdelaney72
|
||||
/homeassistant/components/nobo_hub/ @echoromeo @oyvindwe
|
||||
@@ -1198,6 +1190,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/plex/ @jjlawren
|
||||
/homeassistant/components/plugwise/ @CoMPaTech @bouwew
|
||||
/tests/components/plugwise/ @CoMPaTech @bouwew
|
||||
/homeassistant/components/plum_lightpad/ @ColinHarrington @prystupa
|
||||
/tests/components/plum_lightpad/ @ColinHarrington @prystupa
|
||||
/homeassistant/components/point/ @fredrike
|
||||
/tests/components/point/ @fredrike
|
||||
/homeassistant/components/pooldose/ @lmaertin
|
||||
@@ -1413,8 +1407,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/sfr_box/ @epenet
|
||||
/homeassistant/components/sftp_storage/ @maretodoric
|
||||
/tests/components/sftp_storage/ @maretodoric
|
||||
/homeassistant/components/sharkiq/ @JeffResc @funkybunch @TheOneOgre
|
||||
/tests/components/sharkiq/ @JeffResc @funkybunch @TheOneOgre
|
||||
/homeassistant/components/sharkiq/ @JeffResc @funkybunch
|
||||
/tests/components/sharkiq/ @JeffResc @funkybunch
|
||||
/homeassistant/components/shell_command/ @home-assistant/core
|
||||
/tests/components/shell_command/ @home-assistant/core
|
||||
/homeassistant/components/shelly/ @bieniu @thecode @chemelli74 @bdraco
|
||||
@@ -1479,8 +1473,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/snoo/ @Lash-L
|
||||
/homeassistant/components/snooz/ @AustinBrunkhorst
|
||||
/tests/components/snooz/ @AustinBrunkhorst
|
||||
/homeassistant/components/solaredge/ @frenck @bdraco @tronikos
|
||||
/tests/components/solaredge/ @frenck @bdraco @tronikos
|
||||
/homeassistant/components/solaredge/ @frenck @bdraco
|
||||
/tests/components/solaredge/ @frenck @bdraco
|
||||
/homeassistant/components/solaredge_local/ @drobtravels @scheric
|
||||
/homeassistant/components/solarlog/ @Ernst79 @dontinelli
|
||||
/tests/components/solarlog/ @Ernst79 @dontinelli
|
||||
|
@@ -34,11 +34,9 @@ WORKDIR /usr/src
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||
|
||||
RUN uv python install 3.13.2
|
||||
|
||||
USER vscode
|
||||
|
||||
ENV UV_PYTHON=3.13.2
|
||||
RUN uv python install
|
||||
|
||||
ENV VIRTUAL_ENV="/home/vscode/.local/ha-venv"
|
||||
RUN uv venv $VIRTUAL_ENV
|
||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
|
@@ -616,34 +616,34 @@ async def async_enable_logging(
|
||||
),
|
||||
)
|
||||
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(logging.INFO if verbose else logging.WARNING)
|
||||
|
||||
# Log errors to a file if we have write access to file or config dir
|
||||
if log_file is None:
|
||||
default_log_path = hass.config.path(ERROR_LOG_FILENAME)
|
||||
if "SUPERVISOR" in os.environ:
|
||||
_LOGGER.info("Running in Supervisor, not logging to file")
|
||||
# Rename the default log file if it exists, since previous versions created
|
||||
# it even on Supervisor
|
||||
if os.path.isfile(default_log_path):
|
||||
with contextlib.suppress(OSError):
|
||||
os.rename(default_log_path, f"{default_log_path}.old")
|
||||
err_log_path = None
|
||||
else:
|
||||
err_log_path = default_log_path
|
||||
err_log_path = hass.config.path(ERROR_LOG_FILENAME)
|
||||
else:
|
||||
err_log_path = os.path.abspath(log_file)
|
||||
|
||||
if err_log_path:
|
||||
err_path_exists = os.path.isfile(err_log_path)
|
||||
err_dir = os.path.dirname(err_log_path)
|
||||
|
||||
# Check if we can write to the error log if it exists or that
|
||||
# we can create files in the containing directory if not.
|
||||
if (err_path_exists and os.access(err_log_path, os.W_OK)) or (
|
||||
not err_path_exists and os.access(err_dir, os.W_OK)
|
||||
):
|
||||
err_handler = await hass.async_add_executor_job(
|
||||
_create_log_file, err_log_path, log_rotate_days
|
||||
)
|
||||
|
||||
err_handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME))
|
||||
|
||||
logger = logging.getLogger()
|
||||
logger.addHandler(err_handler)
|
||||
logger.setLevel(logging.INFO if verbose else logging.WARNING)
|
||||
|
||||
# Save the log file location for access by other components.
|
||||
hass.data[DATA_LOGGING] = err_log_path
|
||||
else:
|
||||
_LOGGER.error("Unable to set up error log %s (access denied)", err_log_path)
|
||||
|
||||
async_activate_log_queue_handler(hass)
|
||||
|
||||
|
5
homeassistant/brands/ibm.json
Normal file
5
homeassistant/brands/ibm.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "ibm",
|
||||
"name": "IBM",
|
||||
"integrations": ["watson_iot", "watson_tts"]
|
||||
}
|
@@ -12,13 +12,11 @@ from homeassistant.components.bluetooth import async_get_scanner
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ADDRESS
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import CONF_IS_NEW_STYLE_SCALE
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=15)
|
||||
UPDATE_DEBOUNCE_TIME = 0.2
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -40,19 +38,11 @@ class AcaiaCoordinator(DataUpdateCoordinator[None]):
|
||||
config_entry=entry,
|
||||
)
|
||||
|
||||
debouncer = Debouncer(
|
||||
hass=hass,
|
||||
logger=_LOGGER,
|
||||
cooldown=UPDATE_DEBOUNCE_TIME,
|
||||
immediate=True,
|
||||
function=self.async_update_listeners,
|
||||
)
|
||||
|
||||
self._scale = AcaiaScale(
|
||||
address_or_ble_device=entry.data[CONF_ADDRESS],
|
||||
name=entry.title,
|
||||
is_new_style_scale=entry.data[CONF_IS_NEW_STYLE_SCALE],
|
||||
notify_callback=debouncer.async_schedule_call,
|
||||
notify_callback=self.async_update_listeners,
|
||||
scanner=async_get_scanner(hass),
|
||||
)
|
||||
|
||||
|
@@ -71,14 +71,7 @@ class AemetConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=schema,
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"api_key_url": "https://opendata.aemet.es/centrodedescargas/altaUsuario"
|
||||
},
|
||||
)
|
||||
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
|
@@ -14,7 +14,7 @@
|
||||
"longitude": "[%key:common::config_flow::data::longitude%]",
|
||||
"name": "Name of the integration"
|
||||
},
|
||||
"description": "To generate API key go to {api_key_url}"
|
||||
"description": "To generate API key go to https://opendata.aemet.es/centrodedescargas/altaUsuario"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -18,10 +18,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import CONF_USE_NEAREST, DOMAIN, NO_AIRLY_SENSORS
|
||||
|
||||
DESCRIPTION_PLACEHOLDERS = {
|
||||
"developer_registration_url": "https://developer.airly.eu/register",
|
||||
}
|
||||
|
||||
|
||||
class AirlyFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Config flow for Airly."""
|
||||
@@ -89,7 +85,6 @@ class AirlyFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders=DESCRIPTION_PLACEHOLDERS,
|
||||
)
|
||||
|
||||
|
||||
|
@@ -2,7 +2,7 @@
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "To generate API key go to {developer_registration_url}",
|
||||
"description": "To generate API key go to https://developer.airly.eu/register",
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
|
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -15,7 +14,7 @@ from airos.exceptions import (
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
@@ -25,11 +24,6 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.data_entry_flow import section
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import (
|
||||
TextSelector,
|
||||
TextSelectorConfig,
|
||||
TextSelectorType,
|
||||
)
|
||||
|
||||
from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADVANCED_SETTINGS
|
||||
from .coordinator import AirOS8
|
||||
@@ -60,107 +54,50 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 2
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
super().__init__()
|
||||
self.airos_device: AirOS8
|
||||
self.errors: dict[str, str] = {}
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
self,
|
||||
user_input: dict[str, Any] | None = None,
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the manual input of host and credentials."""
|
||||
self.errors = {}
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
validated_info = await self._validate_and_get_device_info(user_input)
|
||||
if validated_info:
|
||||
return self.async_create_entry(
|
||||
title=validated_info["title"],
|
||||
data=validated_info["data"],
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=self.errors
|
||||
)
|
||||
# By default airOS 8 comes with self-signed SSL certificates,
|
||||
# with no option in the web UI to change or upload a custom certificate.
|
||||
session = async_get_clientsession(
|
||||
self.hass,
|
||||
verify_ssl=user_input[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL],
|
||||
)
|
||||
|
||||
async def _validate_and_get_device_info(
|
||||
self, config_data: dict[str, Any]
|
||||
) -> dict[str, Any] | None:
|
||||
"""Validate user input with the device API."""
|
||||
# By default airOS 8 comes with self-signed SSL certificates,
|
||||
# with no option in the web UI to change or upload a custom certificate.
|
||||
session = async_get_clientsession(
|
||||
self.hass,
|
||||
verify_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL],
|
||||
)
|
||||
airos_device = AirOS8(
|
||||
host=user_input[CONF_HOST],
|
||||
username=user_input[CONF_USERNAME],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
session=session,
|
||||
use_ssl=user_input[SECTION_ADVANCED_SETTINGS][CONF_SSL],
|
||||
)
|
||||
try:
|
||||
await airos_device.login()
|
||||
airos_data = await airos_device.status()
|
||||
|
||||
airos_device = AirOS8(
|
||||
host=config_data[CONF_HOST],
|
||||
username=config_data[CONF_USERNAME],
|
||||
password=config_data[CONF_PASSWORD],
|
||||
session=session,
|
||||
use_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_SSL],
|
||||
)
|
||||
try:
|
||||
await airos_device.login()
|
||||
airos_data = await airos_device.status()
|
||||
|
||||
except (
|
||||
AirOSConnectionSetupError,
|
||||
AirOSDeviceConnectionError,
|
||||
):
|
||||
self.errors["base"] = "cannot_connect"
|
||||
except (AirOSConnectionAuthenticationError, AirOSDataMissingError):
|
||||
self.errors["base"] = "invalid_auth"
|
||||
except AirOSKeyDataMissingError:
|
||||
self.errors["base"] = "key_data_missing"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception during credential validation")
|
||||
self.errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(airos_data.derived.mac)
|
||||
|
||||
if self.source == SOURCE_REAUTH:
|
||||
self._abort_if_unique_id_mismatch()
|
||||
except (
|
||||
AirOSConnectionSetupError,
|
||||
AirOSDeviceConnectionError,
|
||||
):
|
||||
errors["base"] = "cannot_connect"
|
||||
except (AirOSConnectionAuthenticationError, AirOSDataMissingError):
|
||||
errors["base"] = "invalid_auth"
|
||||
except AirOSKeyDataMissingError:
|
||||
errors["base"] = "key_data_missing"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(airos_data.derived.mac)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return {"title": airos_data.host.hostname, "data": config_data}
|
||||
|
||||
return None
|
||||
|
||||
async def async_step_reauth(
|
||||
self,
|
||||
user_input: Mapping[str, Any],
|
||||
) -> ConfigFlowResult:
|
||||
"""Perform reauthentication upon an API authentication error."""
|
||||
return await self.async_step_reauth_confirm(user_input)
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self,
|
||||
user_input: Mapping[str, Any],
|
||||
) -> ConfigFlowResult:
|
||||
"""Perform reauthentication upon an API authentication error."""
|
||||
self.errors = {}
|
||||
|
||||
if user_input:
|
||||
validate_data = {**self._get_reauth_entry().data, **user_input}
|
||||
if await self._validate_and_get_device_info(config_data=validate_data):
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(),
|
||||
data_updates=validate_data,
|
||||
return self.async_create_entry(
|
||||
title=airos_data.host.hostname, data=user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PASSWORD): TextSelector(
|
||||
TextSelectorConfig(
|
||||
type=TextSelectorType.PASSWORD,
|
||||
autocomplete="current-password",
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
errors=self.errors,
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
@@ -14,7 +14,7 @@ from airos.exceptions import (
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, SCAN_INTERVAL
|
||||
@@ -47,9 +47,9 @@ class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOS8Data]):
|
||||
try:
|
||||
await self.airos_device.login()
|
||||
return await self.airos_device.status()
|
||||
except AirOSConnectionAuthenticationError as err:
|
||||
except (AirOSConnectionAuthenticationError,) as err:
|
||||
_LOGGER.exception("Error authenticating with airOS device")
|
||||
raise ConfigEntryAuthFailed(
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN, translation_key="invalid_auth"
|
||||
) from err
|
||||
except (
|
||||
|
@@ -2,14 +2,6 @@
|
||||
"config": {
|
||||
"flow_title": "Ubiquiti airOS device",
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "[%key:component::airos::config::step::user::data_description::password%]"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
@@ -42,9 +34,7 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"unique_id_mismatch": "Re-authentication should be used for the same device not a new one"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
|
@@ -23,10 +23,6 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
URL_API_INTEGRATION = {
|
||||
"url": "https://dashboard.airthings.com/integrations/api-integration"
|
||||
}
|
||||
|
||||
|
||||
class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Airthings."""
|
||||
@@ -41,7 +37,11 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
description_placeholders=URL_API_INTEGRATION,
|
||||
description_placeholders={
|
||||
"url": (
|
||||
"https://dashboard.airthings.com/integrations/api-integration"
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
errors = {}
|
||||
@@ -65,8 +65,5 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_create_entry(title="Airthings", data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
description_placeholders=URL_API_INTEGRATION,
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
@@ -4,9 +4,9 @@
|
||||
"user": {
|
||||
"data": {
|
||||
"id": "ID",
|
||||
"secret": "Secret"
|
||||
},
|
||||
"description": "Log in at {url} to find your credentials"
|
||||
"secret": "Secret",
|
||||
"description": "Login at {url} to find your credentials"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
@@ -6,13 +6,8 @@ import dataclasses
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from airthings_ble import (
|
||||
AirthingsBluetoothDeviceData,
|
||||
AirthingsDevice,
|
||||
UnsupportedDeviceError,
|
||||
)
|
||||
from airthings_ble import AirthingsBluetoothDeviceData, AirthingsDevice
|
||||
from bleak import BleakError
|
||||
from habluetooth import BluetoothServiceInfoBleak
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
@@ -32,7 +27,6 @@ SERVICE_UUIDS = [
|
||||
"b42e4a8e-ade7-11e4-89d3-123b93f75cba",
|
||||
"b42e1c08-ade7-11e4-89d3-123b93f75cba",
|
||||
"b42e3882-ade7-11e4-89d3-123b93f75cba",
|
||||
"b42e90a2-ade7-11e4-89d3-123b93f75cba",
|
||||
]
|
||||
|
||||
|
||||
@@ -43,7 +37,6 @@ class Discovery:
|
||||
name: str
|
||||
discovery_info: BluetoothServiceInfo
|
||||
device: AirthingsDevice
|
||||
data: AirthingsBluetoothDeviceData
|
||||
|
||||
|
||||
def get_name(device: AirthingsDevice) -> str:
|
||||
@@ -51,7 +44,7 @@ def get_name(device: AirthingsDevice) -> str:
|
||||
|
||||
name = device.friendly_name()
|
||||
if identifier := device.identifier:
|
||||
name += f" ({device.model.value}{identifier})"
|
||||
name += f" ({identifier})"
|
||||
return name
|
||||
|
||||
|
||||
@@ -69,8 +62,8 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self._discovered_device: Discovery | None = None
|
||||
self._discovered_devices: dict[str, Discovery] = {}
|
||||
|
||||
async def _get_device(
|
||||
self, data: AirthingsBluetoothDeviceData, discovery_info: BluetoothServiceInfo
|
||||
async def _get_device_data(
|
||||
self, discovery_info: BluetoothServiceInfo
|
||||
) -> AirthingsDevice:
|
||||
ble_device = bluetooth.async_ble_device_from_address(
|
||||
self.hass, discovery_info.address
|
||||
@@ -79,8 +72,10 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.debug("no ble_device in _get_device_data")
|
||||
raise AirthingsDeviceUpdateError("No ble_device")
|
||||
|
||||
airthings = AirthingsBluetoothDeviceData(_LOGGER)
|
||||
|
||||
try:
|
||||
device = await data.update_device(ble_device)
|
||||
data = await airthings.update_device(ble_device)
|
||||
except BleakError as err:
|
||||
_LOGGER.error(
|
||||
"Error connecting to and getting data from %s: %s",
|
||||
@@ -88,15 +83,12 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
err,
|
||||
)
|
||||
raise AirthingsDeviceUpdateError("Failed getting device data") from err
|
||||
except UnsupportedDeviceError:
|
||||
_LOGGER.debug("Skipping unsupported device: %s", discovery_info.name)
|
||||
raise
|
||||
except Exception as err:
|
||||
_LOGGER.error(
|
||||
"Unknown error occurred from %s: %s", discovery_info.address, err
|
||||
)
|
||||
raise
|
||||
return device
|
||||
return data
|
||||
|
||||
async def async_step_bluetooth(
|
||||
self, discovery_info: BluetoothServiceInfo
|
||||
@@ -106,21 +98,17 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
await self.async_set_unique_id(discovery_info.address)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
data = AirthingsBluetoothDeviceData(logger=_LOGGER)
|
||||
|
||||
try:
|
||||
device = await self._get_device(data=data, discovery_info=discovery_info)
|
||||
device = await self._get_device_data(discovery_info)
|
||||
except AirthingsDeviceUpdateError:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
except UnsupportedDeviceError:
|
||||
return self.async_abort(reason="unsupported_device")
|
||||
except Exception:
|
||||
_LOGGER.exception("Unknown error occurred")
|
||||
return self.async_abort(reason="unknown")
|
||||
|
||||
name = get_name(device)
|
||||
self.context["title_placeholders"] = {"name": name}
|
||||
self._discovered_device = Discovery(name, discovery_info, device, data=data)
|
||||
self._discovered_device = Discovery(name, discovery_info, device)
|
||||
|
||||
return await self.async_step_bluetooth_confirm()
|
||||
|
||||
@@ -129,12 +117,6 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm discovery."""
|
||||
if user_input is not None:
|
||||
if (
|
||||
self._discovered_device is not None
|
||||
and self._discovered_device.device.firmware.need_firmware_upgrade
|
||||
):
|
||||
return self.async_abort(reason="firmware_upgrade_required")
|
||||
|
||||
return self.async_create_entry(
|
||||
title=self.context["title_placeholders"]["name"], data={}
|
||||
)
|
||||
@@ -155,9 +137,6 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self._abort_if_unique_id_configured()
|
||||
discovery = self._discovered_devices[address]
|
||||
|
||||
if discovery.device.firmware.need_firmware_upgrade:
|
||||
return self.async_abort(reason="firmware_upgrade_required")
|
||||
|
||||
self.context["title_placeholders"] = {
|
||||
"name": discovery.name,
|
||||
}
|
||||
@@ -167,53 +146,32 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_create_entry(title=discovery.name, data={})
|
||||
|
||||
current_addresses = self._async_current_ids(include_ignore=False)
|
||||
devices: list[BluetoothServiceInfoBleak] = []
|
||||
for discovery_info in async_discovered_service_info(self.hass):
|
||||
address = discovery_info.address
|
||||
if address in current_addresses or address in self._discovered_devices:
|
||||
continue
|
||||
|
||||
if MFCT_ID not in discovery_info.manufacturer_data:
|
||||
continue
|
||||
if not any(uuid in SERVICE_UUIDS for uuid in discovery_info.service_uuids):
|
||||
_LOGGER.debug(
|
||||
"Skipping unsupported device: %s (%s)", discovery_info.name, address
|
||||
)
|
||||
continue
|
||||
devices.append(discovery_info)
|
||||
|
||||
for discovery_info in devices:
|
||||
address = discovery_info.address
|
||||
data = AirthingsBluetoothDeviceData(logger=_LOGGER)
|
||||
if not any(uuid in SERVICE_UUIDS for uuid in discovery_info.service_uuids):
|
||||
continue
|
||||
|
||||
try:
|
||||
device = await self._get_device(data, discovery_info)
|
||||
device = await self._get_device_data(discovery_info)
|
||||
except AirthingsDeviceUpdateError:
|
||||
_LOGGER.error(
|
||||
"Error connecting to and getting data from %s (%s)",
|
||||
discovery_info.name,
|
||||
discovery_info.address,
|
||||
)
|
||||
continue
|
||||
except UnsupportedDeviceError:
|
||||
_LOGGER.debug(
|
||||
"Skipping unsupported device: %s (%s)",
|
||||
discovery_info.name,
|
||||
discovery_info.address,
|
||||
)
|
||||
continue
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
except Exception:
|
||||
_LOGGER.exception("Unknown error occurred")
|
||||
return self.async_abort(reason="unknown")
|
||||
name = get_name(device)
|
||||
_LOGGER.debug("Discovered Airthings device: %s (%s)", name, address)
|
||||
self._discovered_devices[address] = Discovery(
|
||||
name, discovery_info, device, data
|
||||
)
|
||||
self._discovered_devices[address] = Discovery(name, discovery_info, device)
|
||||
|
||||
if not self._discovered_devices:
|
||||
return self.async_abort(reason="no_devices_found")
|
||||
|
||||
titles = {
|
||||
address: get_name(discovery.device)
|
||||
address: discovery.device.name
|
||||
for (address, discovery) in self._discovered_devices.items()
|
||||
}
|
||||
return self.async_show_form(
|
||||
|
@@ -17,10 +17,6 @@
|
||||
{
|
||||
"manufacturer_id": 820,
|
||||
"service_uuid": "b42e3882-ade7-11e4-89d3-123b93f75cba"
|
||||
},
|
||||
{
|
||||
"manufacturer_id": 820,
|
||||
"service_uuid": "b42e90a2-ade7-11e4-89d3-123b93f75cba"
|
||||
}
|
||||
],
|
||||
"codeowners": ["@vincegio", "@LaStrada"],
|
||||
@@ -28,5 +24,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/airthings_ble",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["airthings-ble==1.1.1"]
|
||||
"requirements": ["airthings-ble==0.9.2"]
|
||||
}
|
||||
|
@@ -16,12 +16,10 @@ from homeassistant.components.sensor import (
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
LIGHT_LUX,
|
||||
PERCENTAGE,
|
||||
EntityCategory,
|
||||
Platform,
|
||||
UnitOfPressure,
|
||||
UnitOfSoundPressure,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -114,25 +112,8 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = {
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
),
|
||||
"lux": SensorEntityDescription(
|
||||
key="lux",
|
||||
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||
native_unit_of_measurement=LIGHT_LUX,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
),
|
||||
"noise": SensorEntityDescription(
|
||||
key="noise",
|
||||
translation_key="ambient_noise",
|
||||
device_class=SensorDeviceClass.SOUND_PRESSURE,
|
||||
native_unit_of_measurement=UnitOfSoundPressure.WEIGHTED_DECIBEL_A,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
),
|
||||
}
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@callback
|
||||
def async_migrate(hass: HomeAssistant, address: str, sensor_name: str) -> None:
|
||||
|
@@ -6,9 +6,6 @@
|
||||
"description": "[%key:component::bluetooth::config::step::user::description%]",
|
||||
"data": {
|
||||
"address": "[%key:common::config_flow::data::device%]"
|
||||
},
|
||||
"data_description": {
|
||||
"address": "The Airthings devices discovered via Bluetooth."
|
||||
}
|
||||
},
|
||||
"bluetooth_confirm": {
|
||||
@@ -20,8 +17,6 @@
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"firmware_upgrade_required": "Your device requires a firmware upgrade. Please use the Airthings app (Android/iOS) to upgrade it.",
|
||||
"unsupported_device": "Unsupported device",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
}
|
||||
},
|
||||
@@ -41,9 +36,6 @@
|
||||
},
|
||||
"illuminance": {
|
||||
"name": "[%key:component::sensor::entity_component::illuminance::name%]"
|
||||
},
|
||||
"ambient_noise": {
|
||||
"name": "Ambient noise"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -2,9 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any, Final, final
|
||||
from typing import TYPE_CHECKING, Any, Final, final
|
||||
|
||||
from propcache.api import cached_property
|
||||
import voluptuous as vol
|
||||
@@ -27,6 +28,8 @@ from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.config_validation import make_entity_service_schema
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.entity_platform import EntityPlatform
|
||||
from homeassistant.helpers.frame import ReportBehavior, report_usage
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
@@ -146,11 +149,68 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A
|
||||
)
|
||||
_alarm_control_panel_option_default_code: str | None = None
|
||||
|
||||
__alarm_legacy_state: bool = False
|
||||
|
||||
def __init_subclass__(cls, **kwargs: Any) -> None:
|
||||
"""Post initialisation processing."""
|
||||
super().__init_subclass__(**kwargs)
|
||||
if any(method in cls.__dict__ for method in ("_attr_state", "state")):
|
||||
# Integrations should use the 'alarm_state' property instead of
|
||||
# setting the state directly.
|
||||
cls.__alarm_legacy_state = True
|
||||
|
||||
def __setattr__(self, name: str, value: Any, /) -> None:
|
||||
"""Set attribute.
|
||||
|
||||
Deprecation warning if setting '_attr_state' directly
|
||||
unless already reported.
|
||||
"""
|
||||
if name == "_attr_state":
|
||||
self._report_deprecated_alarm_state_handling()
|
||||
return super().__setattr__(name, value)
|
||||
|
||||
@callback
|
||||
def add_to_platform_start(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
platform: EntityPlatform,
|
||||
parallel_updates: asyncio.Semaphore | None,
|
||||
) -> None:
|
||||
"""Start adding an entity to a platform."""
|
||||
super().add_to_platform_start(hass, platform, parallel_updates)
|
||||
if self.__alarm_legacy_state:
|
||||
self._report_deprecated_alarm_state_handling()
|
||||
|
||||
@callback
|
||||
def _report_deprecated_alarm_state_handling(self) -> None:
|
||||
"""Report on deprecated handling of alarm state.
|
||||
|
||||
Integrations should implement alarm_state instead of using state directly.
|
||||
"""
|
||||
report_usage(
|
||||
"is setting state directly."
|
||||
f" Entity {self.entity_id} ({type(self)}) should implement the 'alarm_state'"
|
||||
" property and return its state using the AlarmControlPanelState enum",
|
||||
core_integration_behavior=ReportBehavior.ERROR,
|
||||
custom_integration_behavior=ReportBehavior.LOG,
|
||||
breaks_in_ha_version="2025.11",
|
||||
integration_domain=self.platform.platform_name if self.platform else None,
|
||||
exclude_integrations={DOMAIN},
|
||||
)
|
||||
|
||||
@final
|
||||
@property
|
||||
def state(self) -> str | None:
|
||||
"""Return the current state."""
|
||||
return self.alarm_state
|
||||
if (alarm_state := self.alarm_state) is not None:
|
||||
return alarm_state
|
||||
if self._attr_state is not None:
|
||||
# Backwards compatibility for integrations that set state directly
|
||||
# Should be removed in 2025.11
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(self._attr_state, str)
|
||||
return self._attr_state
|
||||
return None
|
||||
|
||||
@cached_property
|
||||
def alarm_state(self) -> AlarmControlPanelState | None:
|
||||
|
@@ -1472,10 +1472,10 @@ class AlexaModeController(AlexaCapability):
|
||||
# Return state instead of position when using ModeController.
|
||||
mode = self.entity.state
|
||||
if mode in (
|
||||
cover.CoverState.OPEN,
|
||||
cover.CoverState.OPENING,
|
||||
cover.CoverState.CLOSED,
|
||||
cover.CoverState.CLOSING,
|
||||
cover.STATE_OPEN,
|
||||
cover.STATE_OPENING,
|
||||
cover.STATE_CLOSED,
|
||||
cover.STATE_CLOSING,
|
||||
STATE_UNKNOWN,
|
||||
):
|
||||
return f"{cover.ATTR_POSITION}.{mode}"
|
||||
@@ -1594,11 +1594,11 @@ class AlexaModeController(AlexaCapability):
|
||||
["Position", AlexaGlobalCatalog.SETTING_OPENING], False
|
||||
)
|
||||
self._resource.add_mode(
|
||||
f"{cover.ATTR_POSITION}.{cover.CoverState.OPEN}",
|
||||
f"{cover.ATTR_POSITION}.{cover.STATE_OPEN}",
|
||||
[AlexaGlobalCatalog.VALUE_OPEN],
|
||||
)
|
||||
self._resource.add_mode(
|
||||
f"{cover.ATTR_POSITION}.{cover.CoverState.CLOSED}",
|
||||
f"{cover.ATTR_POSITION}.{cover.STATE_CLOSED}",
|
||||
[AlexaGlobalCatalog.VALUE_CLOSE],
|
||||
)
|
||||
self._resource.add_mode(
|
||||
@@ -1651,22 +1651,22 @@ class AlexaModeController(AlexaCapability):
|
||||
raise_labels.append(AlexaSemantics.ACTION_OPEN)
|
||||
self._semantics.add_states_to_value(
|
||||
[AlexaSemantics.STATES_CLOSED],
|
||||
f"{cover.ATTR_POSITION}.{cover.CoverState.CLOSED}",
|
||||
f"{cover.ATTR_POSITION}.{cover.STATE_CLOSED}",
|
||||
)
|
||||
self._semantics.add_states_to_value(
|
||||
[AlexaSemantics.STATES_OPEN],
|
||||
f"{cover.ATTR_POSITION}.{cover.CoverState.OPEN}",
|
||||
f"{cover.ATTR_POSITION}.{cover.STATE_OPEN}",
|
||||
)
|
||||
|
||||
self._semantics.add_action_to_directive(
|
||||
lower_labels,
|
||||
"SetMode",
|
||||
{"mode": f"{cover.ATTR_POSITION}.{cover.CoverState.CLOSED}"},
|
||||
{"mode": f"{cover.ATTR_POSITION}.{cover.STATE_CLOSED}"},
|
||||
)
|
||||
self._semantics.add_action_to_directive(
|
||||
raise_labels,
|
||||
"SetMode",
|
||||
{"mode": f"{cover.ATTR_POSITION}.{cover.CoverState.OPEN}"},
|
||||
{"mode": f"{cover.ATTR_POSITION}.{cover.STATE_OPEN}"},
|
||||
)
|
||||
|
||||
return self._semantics.serialize_semantics()
|
||||
|
@@ -1261,9 +1261,9 @@ async def async_api_set_mode(
|
||||
elif instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
|
||||
position = mode.split(".")[1]
|
||||
|
||||
if position == cover.CoverState.CLOSED:
|
||||
if position == cover.STATE_CLOSED:
|
||||
service = cover.SERVICE_CLOSE_COVER
|
||||
elif position == cover.CoverState.OPEN:
|
||||
elif position == cover.STATE_OPEN:
|
||||
service = cover.SERVICE_OPEN_COVER
|
||||
elif position == "custom":
|
||||
service = cover.SERVICE_STOP_COVER
|
||||
|
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==6.4.1"]
|
||||
"requirements": ["aioamazondevices==6.4.0"]
|
||||
}
|
||||
|
@@ -65,31 +65,6 @@ SENSOR_DESCRIPTIONS = [
|
||||
suggested_display_precision=2,
|
||||
translation_placeholders={"sensor_name": "BME280"},
|
||||
),
|
||||
AltruistSensorEntityDescription(
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
key="BME680_humidity",
|
||||
translation_key="humidity",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
suggested_display_precision=2,
|
||||
translation_placeholders={"sensor_name": "BME680"},
|
||||
),
|
||||
AltruistSensorEntityDescription(
|
||||
device_class=SensorDeviceClass.PRESSURE,
|
||||
key="BME680_pressure",
|
||||
translation_key="pressure",
|
||||
native_unit_of_measurement=UnitOfPressure.PA,
|
||||
suggested_unit_of_measurement=UnitOfPressure.MMHG,
|
||||
suggested_display_precision=0,
|
||||
translation_placeholders={"sensor_name": "BME680"},
|
||||
),
|
||||
AltruistSensorEntityDescription(
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
key="BME680_temperature",
|
||||
translation_key="temperature",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
suggested_display_precision=2,
|
||||
translation_placeholders={"sensor_name": "BME680"},
|
||||
),
|
||||
AltruistSensorEntityDescription(
|
||||
device_class=SensorDeviceClass.PRESSURE,
|
||||
key="BMP_pressure",
|
||||
|
@@ -629,6 +629,7 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
|
||||
|
||||
devices_info.append(
|
||||
{
|
||||
"entities": [],
|
||||
"entry_type": device_entry.entry_type,
|
||||
"has_configuration_url": device_entry.configuration_url is not None,
|
||||
"hw_version": device_entry.hw_version,
|
||||
@@ -637,7 +638,6 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
|
||||
"model_id": device_entry.model_id,
|
||||
"sw_version": device_entry.sw_version,
|
||||
"via_device": device_entry.via_device_id,
|
||||
"entities": [],
|
||||
}
|
||||
)
|
||||
|
||||
|
@@ -4,15 +4,12 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from functools import partial
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
import anthropic
|
||||
import voluptuous as vol
|
||||
from voluptuous_openapi import convert
|
||||
|
||||
from homeassistant.components.zone import ENTITY_ID_HOME
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigEntryState,
|
||||
@@ -21,13 +18,7 @@ from homeassistant.config_entries import (
|
||||
ConfigSubentryFlow,
|
||||
SubentryFlowResult,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_LATITUDE,
|
||||
ATTR_LONGITUDE,
|
||||
CONF_API_KEY,
|
||||
CONF_LLM_HASS_API,
|
||||
CONF_NAME,
|
||||
)
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import llm
|
||||
from homeassistant.helpers.selector import (
|
||||
@@ -46,23 +37,12 @@ from .const import (
|
||||
CONF_RECOMMENDED,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_THINKING_BUDGET,
|
||||
CONF_WEB_SEARCH,
|
||||
CONF_WEB_SEARCH_CITY,
|
||||
CONF_WEB_SEARCH_COUNTRY,
|
||||
CONF_WEB_SEARCH_MAX_USES,
|
||||
CONF_WEB_SEARCH_REGION,
|
||||
CONF_WEB_SEARCH_TIMEZONE,
|
||||
CONF_WEB_SEARCH_USER_LOCATION,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
DOMAIN,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
RECOMMENDED_MAX_TOKENS,
|
||||
RECOMMENDED_TEMPERATURE,
|
||||
RECOMMENDED_THINKING_BUDGET,
|
||||
RECOMMENDED_WEB_SEARCH,
|
||||
RECOMMENDED_WEB_SEARCH_MAX_USES,
|
||||
RECOMMENDED_WEB_SEARCH_USER_LOCATION,
|
||||
WEB_SEARCH_UNSUPPORTED_MODELS,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -188,14 +168,6 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET
|
||||
) >= user_input.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS):
|
||||
errors[CONF_THINKING_BUDGET] = "thinking_budget_too_large"
|
||||
if user_input.get(CONF_WEB_SEARCH, RECOMMENDED_WEB_SEARCH):
|
||||
model = user_input.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
|
||||
if model.startswith(tuple(WEB_SEARCH_UNSUPPORTED_MODELS)):
|
||||
errors[CONF_WEB_SEARCH] = "web_search_unsupported_model"
|
||||
elif user_input.get(
|
||||
CONF_WEB_SEARCH_USER_LOCATION, RECOMMENDED_WEB_SEARCH_USER_LOCATION
|
||||
):
|
||||
user_input.update(await self._get_location_data())
|
||||
|
||||
if not errors:
|
||||
if self._is_new:
|
||||
@@ -243,68 +215,6 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
errors=errors or None,
|
||||
)
|
||||
|
||||
async def _get_location_data(self) -> dict[str, str]:
|
||||
"""Get approximate location data of the user."""
|
||||
location_data: dict[str, str] = {}
|
||||
zone_home = self.hass.states.get(ENTITY_ID_HOME)
|
||||
if zone_home is not None:
|
||||
client = await self.hass.async_add_executor_job(
|
||||
partial(
|
||||
anthropic.AsyncAnthropic,
|
||||
api_key=self._get_entry().data[CONF_API_KEY],
|
||||
)
|
||||
)
|
||||
location_schema = vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_WEB_SEARCH_CITY,
|
||||
description="Free text input for the city, e.g. `San Francisco`",
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_WEB_SEARCH_REGION,
|
||||
description="Free text input for the region, e.g. `California`",
|
||||
): str,
|
||||
}
|
||||
)
|
||||
response = await client.messages.create(
|
||||
model=RECOMMENDED_CHAT_MODEL,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Where are the following coordinates located: "
|
||||
f"({zone_home.attributes[ATTR_LATITUDE]},"
|
||||
f" {zone_home.attributes[ATTR_LONGITUDE]})? Please respond "
|
||||
"only with a JSON object using the following schema:\n"
|
||||
f"{convert(location_schema)}",
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "{", # hints the model to skip any preamble
|
||||
},
|
||||
],
|
||||
max_tokens=RECOMMENDED_MAX_TOKENS,
|
||||
)
|
||||
_LOGGER.debug("Model response: %s", response.content)
|
||||
location_data = location_schema(
|
||||
json.loads(
|
||||
"{"
|
||||
+ "".join(
|
||||
block.text
|
||||
for block in response.content
|
||||
if isinstance(block, anthropic.types.TextBlock)
|
||||
)
|
||||
)
|
||||
or {}
|
||||
)
|
||||
|
||||
if self.hass.config.country:
|
||||
location_data[CONF_WEB_SEARCH_COUNTRY] = self.hass.config.country
|
||||
location_data[CONF_WEB_SEARCH_TIMEZONE] = self.hass.config.time_zone
|
||||
|
||||
_LOGGER.debug("Location data: %s", location_data)
|
||||
|
||||
return location_data
|
||||
|
||||
async_step_user = async_step_set_options
|
||||
async_step_reconfigure = async_step_set_options
|
||||
|
||||
@@ -363,18 +273,6 @@ def anthropic_config_option_schema(
|
||||
CONF_THINKING_BUDGET,
|
||||
default=RECOMMENDED_THINKING_BUDGET,
|
||||
): int,
|
||||
vol.Optional(
|
||||
CONF_WEB_SEARCH,
|
||||
default=RECOMMENDED_WEB_SEARCH,
|
||||
): bool,
|
||||
vol.Optional(
|
||||
CONF_WEB_SEARCH_MAX_USES,
|
||||
default=RECOMMENDED_WEB_SEARCH_MAX_USES,
|
||||
): int,
|
||||
vol.Optional(
|
||||
CONF_WEB_SEARCH_USER_LOCATION,
|
||||
default=RECOMMENDED_WEB_SEARCH_USER_LOCATION,
|
||||
): bool,
|
||||
}
|
||||
)
|
||||
return schema
|
||||
|
@@ -18,26 +18,10 @@ RECOMMENDED_TEMPERATURE = 1.0
|
||||
CONF_THINKING_BUDGET = "thinking_budget"
|
||||
RECOMMENDED_THINKING_BUDGET = 0
|
||||
MIN_THINKING_BUDGET = 1024
|
||||
CONF_WEB_SEARCH = "web_search"
|
||||
RECOMMENDED_WEB_SEARCH = False
|
||||
CONF_WEB_SEARCH_USER_LOCATION = "user_location"
|
||||
RECOMMENDED_WEB_SEARCH_USER_LOCATION = False
|
||||
CONF_WEB_SEARCH_MAX_USES = "web_search_max_uses"
|
||||
RECOMMENDED_WEB_SEARCH_MAX_USES = 5
|
||||
CONF_WEB_SEARCH_CITY = "city"
|
||||
CONF_WEB_SEARCH_REGION = "region"
|
||||
CONF_WEB_SEARCH_COUNTRY = "country"
|
||||
CONF_WEB_SEARCH_TIMEZONE = "timezone"
|
||||
|
||||
NON_THINKING_MODELS = [
|
||||
"claude-3-5", # Both sonnet and haiku
|
||||
"claude-3-opus",
|
||||
"claude-3-haiku",
|
||||
]
|
||||
|
||||
WEB_SEARCH_UNSUPPORTED_MODELS = [
|
||||
"claude-3-haiku",
|
||||
"claude-3-opus",
|
||||
"claude-3-5-sonnet-20240620",
|
||||
"claude-3-5-sonnet-20241022",
|
||||
THINKING_MODELS = [
|
||||
"claude-3-7-sonnet",
|
||||
"claude-sonnet-4-0",
|
||||
"claude-opus-4-0",
|
||||
"claude-opus-4-1",
|
||||
]
|
||||
|
@@ -1,17 +1,12 @@
|
||||
"""Base entity for Anthropic."""
|
||||
|
||||
from collections.abc import AsyncGenerator, Callable, Iterable
|
||||
from dataclasses import dataclass, field
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import anthropic
|
||||
from anthropic import AsyncStream
|
||||
from anthropic.types import (
|
||||
CitationsDelta,
|
||||
CitationsWebSearchResultLocation,
|
||||
CitationWebSearchResultLocationParam,
|
||||
ContentBlockParam,
|
||||
InputJSONDelta,
|
||||
MessageDeltaUsage,
|
||||
MessageParam,
|
||||
@@ -21,16 +16,11 @@ from anthropic.types import (
|
||||
RawContentBlockStopEvent,
|
||||
RawMessageDeltaEvent,
|
||||
RawMessageStartEvent,
|
||||
RawMessageStopEvent,
|
||||
RedactedThinkingBlock,
|
||||
RedactedThinkingBlockParam,
|
||||
ServerToolUseBlock,
|
||||
ServerToolUseBlockParam,
|
||||
SignatureDelta,
|
||||
TextBlock,
|
||||
TextBlockParam,
|
||||
TextCitation,
|
||||
TextCitationParam,
|
||||
TextDelta,
|
||||
ThinkingBlock,
|
||||
ThinkingBlockParam,
|
||||
@@ -39,15 +29,9 @@ from anthropic.types import (
|
||||
ThinkingDelta,
|
||||
ToolParam,
|
||||
ToolResultBlockParam,
|
||||
ToolUnionParam,
|
||||
ToolUseBlock,
|
||||
ToolUseBlockParam,
|
||||
Usage,
|
||||
WebSearchTool20250305Param,
|
||||
WebSearchToolRequestErrorParam,
|
||||
WebSearchToolResultBlock,
|
||||
WebSearchToolResultBlockParam,
|
||||
WebSearchToolResultError,
|
||||
)
|
||||
from anthropic.types.message_create_params import MessageCreateParamsStreaming
|
||||
from voluptuous_openapi import convert
|
||||
@@ -64,21 +48,14 @@ from .const import (
|
||||
CONF_MAX_TOKENS,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_THINKING_BUDGET,
|
||||
CONF_WEB_SEARCH,
|
||||
CONF_WEB_SEARCH_CITY,
|
||||
CONF_WEB_SEARCH_COUNTRY,
|
||||
CONF_WEB_SEARCH_MAX_USES,
|
||||
CONF_WEB_SEARCH_REGION,
|
||||
CONF_WEB_SEARCH_TIMEZONE,
|
||||
CONF_WEB_SEARCH_USER_LOCATION,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
MIN_THINKING_BUDGET,
|
||||
NON_THINKING_MODELS,
|
||||
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
|
||||
@@ -96,69 +73,6 @@ def _format_tool(
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CitationDetails:
|
||||
"""Citation details for a content part."""
|
||||
|
||||
index: int = 0
|
||||
"""Start position of the text."""
|
||||
|
||||
length: int = 0
|
||||
"""Length of the relevant data."""
|
||||
|
||||
citations: list[TextCitationParam] = field(default_factory=list)
|
||||
"""Citations for the content part."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ContentDetails:
|
||||
"""Native data for AssistantContent."""
|
||||
|
||||
citation_details: list[CitationDetails] = field(default_factory=list)
|
||||
|
||||
def has_content(self) -> bool:
|
||||
"""Check if there is any content."""
|
||||
return any(detail.length > 0 for detail in self.citation_details)
|
||||
|
||||
def has_citations(self) -> bool:
|
||||
"""Check if there are any citations."""
|
||||
return any(detail.citations for detail in self.citation_details)
|
||||
|
||||
def add_citation_detail(self) -> None:
|
||||
"""Add a new citation detail."""
|
||||
if not self.citation_details or self.citation_details[-1].length > 0:
|
||||
self.citation_details.append(
|
||||
CitationDetails(
|
||||
index=self.citation_details[-1].index
|
||||
+ self.citation_details[-1].length
|
||||
if self.citation_details
|
||||
else 0
|
||||
)
|
||||
)
|
||||
|
||||
def add_citation(self, citation: TextCitation) -> None:
|
||||
"""Add a citation to the current detail."""
|
||||
if not self.citation_details:
|
||||
self.citation_details.append(CitationDetails())
|
||||
citation_param: TextCitationParam | None = None
|
||||
if isinstance(citation, CitationsWebSearchResultLocation):
|
||||
citation_param = CitationWebSearchResultLocationParam(
|
||||
type="web_search_result_location",
|
||||
title=citation.title,
|
||||
url=citation.url,
|
||||
cited_text=citation.cited_text,
|
||||
encrypted_index=citation.encrypted_index,
|
||||
)
|
||||
if citation_param:
|
||||
self.citation_details[-1].citations.append(citation_param)
|
||||
|
||||
def delete_empty(self) -> None:
|
||||
"""Delete empty citation details."""
|
||||
self.citation_details = [
|
||||
detail for detail in self.citation_details if detail.citations
|
||||
]
|
||||
|
||||
|
||||
def _convert_content(
|
||||
chat_content: Iterable[conversation.Content],
|
||||
) -> list[MessageParam]:
|
||||
@@ -167,31 +81,15 @@ def _convert_content(
|
||||
|
||||
for content in chat_content:
|
||||
if isinstance(content, conversation.ToolResultContent):
|
||||
if content.tool_name == "web_search":
|
||||
tool_result_block: ContentBlockParam = WebSearchToolResultBlockParam(
|
||||
type="web_search_tool_result",
|
||||
tool_use_id=content.tool_call_id,
|
||||
content=content.tool_result["content"]
|
||||
if "content" in content.tool_result
|
||||
else WebSearchToolRequestErrorParam(
|
||||
type="web_search_tool_result_error",
|
||||
error_code=content.tool_result.get("error_code", "unavailable"), # type: ignore[typeddict-item]
|
||||
),
|
||||
)
|
||||
external_tool = True
|
||||
else:
|
||||
tool_result_block = ToolResultBlockParam(
|
||||
type="tool_result",
|
||||
tool_use_id=content.tool_call_id,
|
||||
content=json.dumps(content.tool_result),
|
||||
)
|
||||
external_tool = False
|
||||
if not messages or messages[-1]["role"] != (
|
||||
"assistant" if external_tool else "user"
|
||||
):
|
||||
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="assistant" if external_tool else "user",
|
||||
role="user",
|
||||
content=[tool_result_block],
|
||||
)
|
||||
)
|
||||
@@ -253,56 +151,13 @@ def _convert_content(
|
||||
redacted_thinking_block
|
||||
)
|
||||
if content.content:
|
||||
current_index = 0
|
||||
for detail in (
|
||||
content.native.citation_details
|
||||
if isinstance(content.native, ContentDetails)
|
||||
else [CitationDetails(length=len(content.content))]
|
||||
):
|
||||
if detail.index > current_index:
|
||||
# Add text block for any text without citations
|
||||
messages[-1]["content"].append( # type: ignore[union-attr]
|
||||
TextBlockParam(
|
||||
type="text",
|
||||
text=content.content[current_index : detail.index],
|
||||
)
|
||||
)
|
||||
messages[-1]["content"].append( # type: ignore[union-attr]
|
||||
TextBlockParam(
|
||||
type="text",
|
||||
text=content.content[
|
||||
detail.index : detail.index + detail.length
|
||||
],
|
||||
citations=detail.citations,
|
||||
)
|
||||
if detail.citations
|
||||
else TextBlockParam(
|
||||
type="text",
|
||||
text=content.content[
|
||||
detail.index : detail.index + detail.length
|
||||
],
|
||||
)
|
||||
)
|
||||
current_index = detail.index + detail.length
|
||||
if current_index < len(content.content):
|
||||
# Add text block for any remaining text without citations
|
||||
messages[-1]["content"].append( # type: ignore[union-attr]
|
||||
TextBlockParam(
|
||||
type="text",
|
||||
text=content.content[current_index:],
|
||||
)
|
||||
)
|
||||
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]
|
||||
[
|
||||
ServerToolUseBlockParam(
|
||||
type="server_tool_use",
|
||||
id=tool_call.id,
|
||||
name="web_search",
|
||||
input=tool_call.tool_args,
|
||||
)
|
||||
if tool_call.external and tool_call.tool_name == "web_search"
|
||||
else ToolUseBlockParam(
|
||||
ToolUseBlockParam(
|
||||
type="tool_use",
|
||||
id=tool_call.id,
|
||||
name=tool_call.tool_name,
|
||||
@@ -318,12 +173,10 @@ def _convert_content(
|
||||
return messages
|
||||
|
||||
|
||||
async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place
|
||||
async def _transform_stream(
|
||||
chat_log: conversation.ChatLog,
|
||||
stream: AsyncStream[MessageStreamEvent],
|
||||
) -> AsyncGenerator[
|
||||
conversation.AssistantContentDeltaDict | conversation.ToolResultContentDeltaDict
|
||||
]:
|
||||
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
|
||||
"""Transform the response stream into HA format.
|
||||
|
||||
A typical stream of responses might look something like the following:
|
||||
@@ -356,13 +209,11 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
if stream is None:
|
||||
raise TypeError("Expected a stream of messages")
|
||||
|
||||
current_tool_block: ToolUseBlockParam | ServerToolUseBlockParam | None = None
|
||||
current_tool_block: ToolUseBlockParam | None = None
|
||||
current_tool_args: str
|
||||
content_details = ContentDetails()
|
||||
content_details.add_citation_detail()
|
||||
input_usage: Usage | None = None
|
||||
has_content = False
|
||||
has_native = False
|
||||
first_block: bool
|
||||
|
||||
async for response in stream:
|
||||
LOGGER.debug("Received response: %s", response)
|
||||
@@ -371,7 +222,6 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
if response.message.role != "assistant":
|
||||
raise ValueError("Unexpected message role")
|
||||
input_usage = response.message.usage
|
||||
first_block = True
|
||||
elif isinstance(response, RawContentBlockStartEvent):
|
||||
if isinstance(response.content_block, ToolUseBlock):
|
||||
current_tool_block = ToolUseBlockParam(
|
||||
@@ -382,37 +232,17 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
)
|
||||
current_tool_args = ""
|
||||
elif isinstance(response.content_block, TextBlock):
|
||||
if ( # Do not start a new assistant content just for citations, concatenate consecutive blocks with citations instead.
|
||||
first_block
|
||||
or (
|
||||
not content_details.has_citations()
|
||||
and response.content_block.citations is None
|
||||
and content_details.has_content()
|
||||
)
|
||||
):
|
||||
if content_details.has_citations():
|
||||
content_details.delete_empty()
|
||||
yield {"native": content_details}
|
||||
content_details = ContentDetails()
|
||||
if has_content:
|
||||
yield {"role": "assistant"}
|
||||
has_native = False
|
||||
first_block = False
|
||||
content_details.add_citation_detail()
|
||||
has_content = True
|
||||
if response.content_block.text:
|
||||
content_details.citation_details[-1].length += len(
|
||||
response.content_block.text
|
||||
)
|
||||
yield {"content": response.content_block.text}
|
||||
elif isinstance(response.content_block, ThinkingBlock):
|
||||
if first_block or has_native:
|
||||
if content_details.has_citations():
|
||||
content_details.delete_empty()
|
||||
yield {"native": content_details}
|
||||
content_details = ContentDetails()
|
||||
content_details.add_citation_detail()
|
||||
if has_native:
|
||||
yield {"role": "assistant"}
|
||||
has_native = False
|
||||
first_block = False
|
||||
has_content = False
|
||||
elif isinstance(response.content_block, RedactedThinkingBlock):
|
||||
LOGGER.debug(
|
||||
"Some of Claude’s internal reasoning has been automatically "
|
||||
@@ -420,60 +250,15 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
"responses"
|
||||
)
|
||||
if has_native:
|
||||
if content_details.has_citations():
|
||||
content_details.delete_empty()
|
||||
yield {"native": content_details}
|
||||
content_details = ContentDetails()
|
||||
content_details.add_citation_detail()
|
||||
yield {"role": "assistant"}
|
||||
has_native = False
|
||||
first_block = False
|
||||
has_content = False
|
||||
yield {"native": response.content_block}
|
||||
has_native = True
|
||||
elif isinstance(response.content_block, ServerToolUseBlock):
|
||||
current_tool_block = ServerToolUseBlockParam(
|
||||
type="server_tool_use",
|
||||
id=response.content_block.id,
|
||||
name=response.content_block.name,
|
||||
input="",
|
||||
)
|
||||
current_tool_args = ""
|
||||
elif isinstance(response.content_block, WebSearchToolResultBlock):
|
||||
if content_details.has_citations():
|
||||
content_details.delete_empty()
|
||||
yield {"native": content_details}
|
||||
content_details = ContentDetails()
|
||||
content_details.add_citation_detail()
|
||||
yield {
|
||||
"role": "tool_result",
|
||||
"tool_call_id": response.content_block.tool_use_id,
|
||||
"tool_name": "web_search",
|
||||
"tool_result": {
|
||||
"type": "web_search_tool_result_error",
|
||||
"error_code": response.content_block.content.error_code,
|
||||
}
|
||||
if isinstance(
|
||||
response.content_block.content, WebSearchToolResultError
|
||||
)
|
||||
else {
|
||||
"content": [
|
||||
{
|
||||
"type": "web_search_result",
|
||||
"encrypted_content": block.encrypted_content,
|
||||
"page_age": block.page_age,
|
||||
"title": block.title,
|
||||
"url": block.url,
|
||||
}
|
||||
for block in response.content_block.content
|
||||
]
|
||||
},
|
||||
}
|
||||
first_block = True
|
||||
elif isinstance(response, RawContentBlockDeltaEvent):
|
||||
if isinstance(response.delta, InputJSONDelta):
|
||||
current_tool_args += response.delta.partial_json
|
||||
elif isinstance(response.delta, TextDelta):
|
||||
content_details.citation_details[-1].length += len(response.delta.text)
|
||||
yield {"content": response.delta.text}
|
||||
elif isinstance(response.delta, ThinkingDelta):
|
||||
yield {"thinking_content": response.delta.thinking}
|
||||
@@ -486,8 +271,6 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
)
|
||||
}
|
||||
has_native = True
|
||||
elif isinstance(response.delta, CitationsDelta):
|
||||
content_details.add_citation(response.delta.citation)
|
||||
elif isinstance(response, RawContentBlockStopEvent):
|
||||
if current_tool_block is not None:
|
||||
tool_args = json.loads(current_tool_args) if current_tool_args else {}
|
||||
@@ -498,7 +281,6 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
id=current_tool_block["id"],
|
||||
tool_name=current_tool_block["name"],
|
||||
tool_args=tool_args,
|
||||
external=current_tool_block["type"] == "server_tool_use",
|
||||
)
|
||||
]
|
||||
}
|
||||
@@ -508,12 +290,6 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
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 content_details.has_citations():
|
||||
content_details.delete_empty()
|
||||
yield {"native": content_details}
|
||||
content_details = ContentDetails()
|
||||
content_details.add_citation_detail()
|
||||
|
||||
|
||||
def _create_token_stats(
|
||||
@@ -561,11 +337,21 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
"""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)
|
||||
|
||||
model_args = MessageCreateParamsStreaming(
|
||||
@@ -575,10 +361,10 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
system=system.content,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
thinking_budget = options.get(CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET)
|
||||
if tools:
|
||||
model_args["tools"] = tools
|
||||
if (
|
||||
not model.startswith(tuple(NON_THINKING_MODELS))
|
||||
model.startswith(tuple(THINKING_MODELS))
|
||||
and thinking_budget >= MIN_THINKING_BUDGET
|
||||
):
|
||||
model_args["thinking"] = ThinkingConfigEnabledParam(
|
||||
@@ -590,34 +376,6 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
|
||||
)
|
||||
|
||||
tools: list[ToolUnionParam] = []
|
||||
if chat_log.llm_api:
|
||||
tools = [
|
||||
_format_tool(tool, chat_log.llm_api.custom_serializer)
|
||||
for tool in chat_log.llm_api.tools
|
||||
]
|
||||
|
||||
if options.get(CONF_WEB_SEARCH):
|
||||
web_search = WebSearchTool20250305Param(
|
||||
name="web_search",
|
||||
type="web_search_20250305",
|
||||
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
|
||||
)
|
||||
if options.get(CONF_WEB_SEARCH_USER_LOCATION):
|
||||
web_search["user_location"] = {
|
||||
"type": "approximate",
|
||||
"city": options.get(CONF_WEB_SEARCH_CITY, ""),
|
||||
"region": options.get(CONF_WEB_SEARCH_REGION, ""),
|
||||
"country": options.get(CONF_WEB_SEARCH_COUNTRY, ""),
|
||||
"timezone": options.get(CONF_WEB_SEARCH_TIMEZONE, ""),
|
||||
}
|
||||
tools.append(web_search)
|
||||
|
||||
if tools:
|
||||
model_args["tools"] = tools
|
||||
|
||||
client = self.entry.runtime_data
|
||||
|
||||
# To prevent infinite loops, we limit the number of iterations
|
||||
for _iteration in range(MAX_TOOL_ITERATIONS):
|
||||
try:
|
||||
|
@@ -8,5 +8,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/anthropic",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["anthropic==0.69.0"]
|
||||
"requirements": ["anthropic==0.62.0"]
|
||||
}
|
||||
|
@@ -35,17 +35,11 @@
|
||||
"temperature": "Temperature",
|
||||
"llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]",
|
||||
"recommended": "Recommended model settings",
|
||||
"thinking_budget": "Thinking budget",
|
||||
"web_search": "Enable web search",
|
||||
"web_search_max_uses": "Maximum web searches",
|
||||
"user_location": "Include home location"
|
||||
"thinking_budget_tokens": "Thinking budget"
|
||||
},
|
||||
"data_description": {
|
||||
"prompt": "Instruct how the LLM should respond. This can be a template.",
|
||||
"thinking_budget": "The number of tokens the model can use to think about the response out of the total maximum number of tokens. Set to 1024 or greater to enable extended thinking.",
|
||||
"web_search": "The web search tool gives Claude direct access to real-time web content, allowing it to answer questions with up-to-date information beyond its knowledge cutoff",
|
||||
"web_search_max_uses": "Limit the number of searches performed per response",
|
||||
"user_location": "Localize search results based on home location"
|
||||
"thinking_budget_tokens": "The number of tokens the model can use to think about the response out of the total maximum number of tokens. Set to 1024 or greater to enable extended thinking."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -54,8 +48,7 @@
|
||||
"entry_not_loaded": "Cannot add things while the configuration is disabled."
|
||||
},
|
||||
"error": {
|
||||
"thinking_budget_too_large": "Maximum tokens must be greater than the thinking budget.",
|
||||
"web_search_unsupported_model": "Web search is not supported by the selected model. Please choose a compatible model or disable web search."
|
||||
"thinking_budget_too_large": "Maximum tokens must be greater than the thinking budget."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -7,8 +7,6 @@ from typing import Any
|
||||
from pyaprilaire.const import Attribute
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ATTR_TARGET_TEMP_HIGH,
|
||||
ATTR_TARGET_TEMP_LOW,
|
||||
FAN_AUTO,
|
||||
FAN_ON,
|
||||
PRESET_AWAY,
|
||||
@@ -18,12 +16,7 @@ from homeassistant.components.climate import (
|
||||
HVACAction,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_TEMPERATURE,
|
||||
PRECISION_HALVES,
|
||||
PRECISION_WHOLE,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.const import PRECISION_HALVES, PRECISION_WHOLE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -239,15 +232,15 @@ class AprilaireClimate(BaseAprilaireEntity, ClimateEntity):
|
||||
cool_setpoint = 0
|
||||
heat_setpoint = 0
|
||||
|
||||
if temperature := kwargs.get(ATTR_TEMPERATURE):
|
||||
if temperature := kwargs.get("temperature"):
|
||||
if self.coordinator.data.get(Attribute.MODE) == 3:
|
||||
cool_setpoint = temperature
|
||||
else:
|
||||
heat_setpoint = temperature
|
||||
else:
|
||||
if target_temp_low := kwargs.get(ATTR_TARGET_TEMP_LOW):
|
||||
if target_temp_low := kwargs.get("target_temp_low"):
|
||||
heat_setpoint = target_temp_low
|
||||
if target_temp_high := kwargs.get(ATTR_TARGET_TEMP_HIGH):
|
||||
if target_temp_high := kwargs.get("target_temp_high"):
|
||||
cool_setpoint = target_temp_high
|
||||
|
||||
if cool_setpoint == 0 and heat_setpoint == 0:
|
||||
|
@@ -2,7 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, TypeVar
|
||||
|
||||
T = TypeVar("T", dict[str, Any], list[Any], None)
|
||||
|
||||
TRANSLATION_MAP = {
|
||||
"wan_rx": "sensor_rx_bytes",
|
||||
@@ -34,7 +36,7 @@ def clean_dict(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
return {k: v for k, v in raw.items() if v is not None or k.endswith("state")}
|
||||
|
||||
|
||||
def translate_to_legacy[T: (dict[str, Any], list[Any], None)](raw: T) -> T:
|
||||
def translate_to_legacy(raw: T) -> T:
|
||||
"""Translate raw data to legacy format for dicts and lists."""
|
||||
|
||||
if raw is None:
|
||||
|
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/autarco",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["autarco==3.2.0"]
|
||||
"requirements": ["autarco==3.1.0"]
|
||||
}
|
||||
|
@@ -26,6 +26,9 @@ async def async_setup_entry(
|
||||
|
||||
if CONF_HOST in config_entry.data:
|
||||
coordinator = AwairLocalDataUpdateCoordinator(hass, config_entry, session)
|
||||
config_entry.async_on_unload(
|
||||
config_entry.add_update_listener(_async_update_listener)
|
||||
)
|
||||
else:
|
||||
coordinator = AwairCloudDataUpdateCoordinator(hass, config_entry, session)
|
||||
|
||||
@@ -33,11 +36,6 @@ async def async_setup_entry(
|
||||
|
||||
config_entry.runtime_data = coordinator
|
||||
|
||||
if CONF_HOST in config_entry.data:
|
||||
config_entry.async_on_unload(
|
||||
config_entry.add_update_listener(_async_update_listener)
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
@@ -17,7 +17,6 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import frame
|
||||
from homeassistant.util import slugify
|
||||
from homeassistant.util.async_iterator import AsyncIteratorReader, AsyncIteratorWriter
|
||||
|
||||
from . import util
|
||||
from .agent import BackupAgent
|
||||
@@ -145,7 +144,7 @@ class DownloadBackupView(HomeAssistantView):
|
||||
return Response(status=HTTPStatus.NOT_FOUND)
|
||||
else:
|
||||
stream = await agent.async_download_backup(backup_id)
|
||||
reader = cast(IO[bytes], AsyncIteratorReader(hass.loop, stream))
|
||||
reader = cast(IO[bytes], util.AsyncIteratorReader(hass, stream))
|
||||
|
||||
worker_done_event = asyncio.Event()
|
||||
|
||||
@@ -153,7 +152,7 @@ class DownloadBackupView(HomeAssistantView):
|
||||
"""Call by the worker thread when it's done."""
|
||||
hass.loop.call_soon_threadsafe(worker_done_event.set)
|
||||
|
||||
stream = AsyncIteratorWriter(hass.loop)
|
||||
stream = util.AsyncIteratorWriter(hass)
|
||||
worker = threading.Thread(
|
||||
target=util.decrypt_backup,
|
||||
args=[backup, reader, stream, password, on_done, 0, []],
|
||||
|
@@ -38,7 +38,6 @@ from homeassistant.helpers import (
|
||||
)
|
||||
from homeassistant.helpers.json import json_bytes
|
||||
from homeassistant.util import dt as dt_util, json as json_util
|
||||
from homeassistant.util.async_iterator import AsyncIteratorReader
|
||||
|
||||
from . import util as backup_util
|
||||
from .agent import (
|
||||
@@ -73,6 +72,7 @@ from .models import (
|
||||
)
|
||||
from .store import BackupStore
|
||||
from .util import (
|
||||
AsyncIteratorReader,
|
||||
DecryptedBackupStreamer,
|
||||
EncryptedBackupStreamer,
|
||||
make_backup_dir,
|
||||
@@ -1525,7 +1525,7 @@ class BackupManager:
|
||||
reader = await self.hass.async_add_executor_job(open, path.as_posix(), "rb")
|
||||
else:
|
||||
backup_stream = await agent.async_download_backup(backup_id)
|
||||
reader = cast(IO[bytes], AsyncIteratorReader(self.hass.loop, backup_stream))
|
||||
reader = cast(IO[bytes], AsyncIteratorReader(self.hass, backup_stream))
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
validate_password_stream, reader, password
|
||||
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine
|
||||
from concurrent.futures import CancelledError, Future
|
||||
import copy
|
||||
from dataclasses import dataclass, replace
|
||||
from io import BytesIO
|
||||
@@ -13,7 +14,7 @@ from pathlib import Path, PurePath
|
||||
from queue import SimpleQueue
|
||||
import tarfile
|
||||
import threading
|
||||
from typing import IO, Any, cast
|
||||
from typing import IO, Any, Self, cast
|
||||
|
||||
import aiohttp
|
||||
from securetar import SecureTarError, SecureTarFile, SecureTarReadError
|
||||
@@ -22,11 +23,6 @@ from homeassistant.backup_restore import password_to_key
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.async_iterator import (
|
||||
Abort,
|
||||
AsyncIteratorReader,
|
||||
AsyncIteratorWriter,
|
||||
)
|
||||
from homeassistant.util.json import JsonObjectType, json_loads_object
|
||||
|
||||
from .const import BUF_SIZE, LOGGER
|
||||
@@ -63,6 +59,12 @@ class BackupEmpty(DecryptError):
|
||||
_message = "No tar files found in the backup."
|
||||
|
||||
|
||||
class AbortCipher(HomeAssistantError):
|
||||
"""Abort the cipher operation."""
|
||||
|
||||
_message = "Abort cipher operation."
|
||||
|
||||
|
||||
def make_backup_dir(path: Path) -> None:
|
||||
"""Create a backup directory if it does not exist."""
|
||||
path.mkdir(exist_ok=True)
|
||||
@@ -164,6 +166,106 @@ def validate_password(path: Path, password: str | None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class AsyncIteratorReader:
|
||||
"""Wrap an AsyncIterator."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, stream: AsyncIterator[bytes]) -> None:
|
||||
"""Initialize the wrapper."""
|
||||
self._aborted = False
|
||||
self._hass = hass
|
||||
self._stream = stream
|
||||
self._buffer: bytes | None = None
|
||||
self._next_future: Future[bytes | None] | None = None
|
||||
self._pos: int = 0
|
||||
|
||||
async def _next(self) -> bytes | None:
|
||||
"""Get the next chunk from the iterator."""
|
||||
return await anext(self._stream, None)
|
||||
|
||||
def abort(self) -> None:
|
||||
"""Abort the reader."""
|
||||
self._aborted = True
|
||||
if self._next_future is not None:
|
||||
self._next_future.cancel()
|
||||
|
||||
def read(self, n: int = -1, /) -> bytes:
|
||||
"""Read data from the iterator."""
|
||||
result = bytearray()
|
||||
while n < 0 or len(result) < n:
|
||||
if not self._buffer:
|
||||
self._next_future = asyncio.run_coroutine_threadsafe(
|
||||
self._next(), self._hass.loop
|
||||
)
|
||||
if self._aborted:
|
||||
self._next_future.cancel()
|
||||
raise AbortCipher
|
||||
try:
|
||||
self._buffer = self._next_future.result()
|
||||
except CancelledError as err:
|
||||
raise AbortCipher from err
|
||||
self._pos = 0
|
||||
if not self._buffer:
|
||||
# The stream is exhausted
|
||||
break
|
||||
chunk = self._buffer[self._pos : self._pos + n]
|
||||
result.extend(chunk)
|
||||
n -= len(chunk)
|
||||
self._pos += len(chunk)
|
||||
if self._pos == len(self._buffer):
|
||||
self._buffer = None
|
||||
return bytes(result)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the iterator."""
|
||||
|
||||
|
||||
class AsyncIteratorWriter:
|
||||
"""Wrap an AsyncIterator."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the wrapper."""
|
||||
self._aborted = False
|
||||
self._hass = hass
|
||||
self._pos: int = 0
|
||||
self._queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=1)
|
||||
self._write_future: Future[bytes | None] | None = None
|
||||
|
||||
def __aiter__(self) -> Self:
|
||||
"""Return the iterator."""
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> bytes:
|
||||
"""Get the next chunk from the iterator."""
|
||||
if data := await self._queue.get():
|
||||
return data
|
||||
raise StopAsyncIteration
|
||||
|
||||
def abort(self) -> None:
|
||||
"""Abort the writer."""
|
||||
self._aborted = True
|
||||
if self._write_future is not None:
|
||||
self._write_future.cancel()
|
||||
|
||||
def tell(self) -> int:
|
||||
"""Return the current position in the iterator."""
|
||||
return self._pos
|
||||
|
||||
def write(self, s: bytes, /) -> int:
|
||||
"""Write data to the iterator."""
|
||||
self._write_future = asyncio.run_coroutine_threadsafe(
|
||||
self._queue.put(s), self._hass.loop
|
||||
)
|
||||
if self._aborted:
|
||||
self._write_future.cancel()
|
||||
raise AbortCipher
|
||||
try:
|
||||
self._write_future.result()
|
||||
except CancelledError as err:
|
||||
raise AbortCipher from err
|
||||
self._pos += len(s)
|
||||
return len(s)
|
||||
|
||||
|
||||
def validate_password_stream(
|
||||
input_stream: IO[bytes],
|
||||
password: str | None,
|
||||
@@ -240,7 +342,7 @@ def decrypt_backup(
|
||||
finally:
|
||||
# Write an empty chunk to signal the end of the stream
|
||||
output_stream.write(b"")
|
||||
except Abort:
|
||||
except AbortCipher:
|
||||
LOGGER.debug("Cipher operation aborted")
|
||||
finally:
|
||||
on_done(error)
|
||||
@@ -328,7 +430,7 @@ def encrypt_backup(
|
||||
finally:
|
||||
# Write an empty chunk to signal the end of the stream
|
||||
output_stream.write(b"")
|
||||
except Abort:
|
||||
except AbortCipher:
|
||||
LOGGER.debug("Cipher operation aborted")
|
||||
finally:
|
||||
on_done(error)
|
||||
@@ -455,8 +557,8 @@ class _CipherBackupStreamer:
|
||||
self._hass.loop.call_soon_threadsafe(worker_status.done.set)
|
||||
|
||||
stream = await self._open_stream()
|
||||
reader = AsyncIteratorReader(self._hass.loop, stream)
|
||||
writer = AsyncIteratorWriter(self._hass.loop)
|
||||
reader = AsyncIteratorReader(self._hass, stream)
|
||||
writer = AsyncIteratorWriter(self._hass)
|
||||
worker = threading.Thread(
|
||||
target=self._cipher_func,
|
||||
args=[
|
||||
|
@@ -73,12 +73,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: BangOlufsenConfigEntry)
|
||||
# Add the websocket and API client
|
||||
entry.runtime_data = BangOlufsenData(websocket, client)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
# Start WebSocket connection once the platforms have been loaded.
|
||||
# This ensures that the initial WebSocket notifications are dispatched to entities
|
||||
# Start WebSocket connection
|
||||
await client.connect_notifications(remote_control=True, reconnect=True)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
@@ -125,8 +125,7 @@ async def async_setup_entry(
|
||||
async_add_entities(
|
||||
new_entities=[
|
||||
BangOlufsenMediaPlayer(config_entry, config_entry.runtime_data.client)
|
||||
],
|
||||
update_before_add=True,
|
||||
]
|
||||
)
|
||||
|
||||
# Register actions.
|
||||
@@ -267,8 +266,34 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
self._software_status.software_version,
|
||||
)
|
||||
|
||||
# Get overall device state once. This is handled by WebSocket events the rest of the time.
|
||||
product_state = await self._client.get_product_state()
|
||||
|
||||
# Get volume information.
|
||||
if product_state.volume:
|
||||
self._volume = product_state.volume
|
||||
|
||||
# Get all playback information.
|
||||
# Ensure that the metadata is not None upon startup
|
||||
if product_state.playback:
|
||||
if product_state.playback.metadata:
|
||||
self._playback_metadata = product_state.playback.metadata
|
||||
self._remote_leader = product_state.playback.metadata.remote_leader
|
||||
if product_state.playback.progress:
|
||||
self._playback_progress = product_state.playback.progress
|
||||
if product_state.playback.source:
|
||||
self._source_change = product_state.playback.source
|
||||
if product_state.playback.state:
|
||||
self._playback_state = product_state.playback.state
|
||||
# Set initial state
|
||||
if self._playback_state.value:
|
||||
self._state = self._playback_state.value
|
||||
|
||||
self._attr_media_position_updated_at = utcnow()
|
||||
|
||||
# Get the highest resolution available of the given images.
|
||||
self._media_image = get_highest_resolution_artwork(self._playback_metadata)
|
||||
|
||||
# If the device has been updated with new sources, then the API will fail here.
|
||||
await self._async_update_sources()
|
||||
|
||||
|
@@ -3,12 +3,16 @@ beolink_allstandby:
|
||||
entity:
|
||||
integration: bang_olufsen
|
||||
domain: media_player
|
||||
device:
|
||||
integration: bang_olufsen
|
||||
|
||||
beolink_expand:
|
||||
target:
|
||||
entity:
|
||||
integration: bang_olufsen
|
||||
domain: media_player
|
||||
device:
|
||||
integration: bang_olufsen
|
||||
fields:
|
||||
all_discovered:
|
||||
required: false
|
||||
@@ -33,6 +37,8 @@ beolink_join:
|
||||
entity:
|
||||
integration: bang_olufsen
|
||||
domain: media_player
|
||||
device:
|
||||
integration: bang_olufsen
|
||||
fields:
|
||||
jid_options:
|
||||
collapsed: false
|
||||
@@ -65,12 +71,16 @@ beolink_leave:
|
||||
entity:
|
||||
integration: bang_olufsen
|
||||
domain: media_player
|
||||
device:
|
||||
integration: bang_olufsen
|
||||
|
||||
beolink_unexpand:
|
||||
target:
|
||||
entity:
|
||||
integration: bang_olufsen
|
||||
domain: media_player
|
||||
device:
|
||||
integration: bang_olufsen
|
||||
fields:
|
||||
jid_options:
|
||||
collapsed: false
|
||||
|
@@ -19,8 +19,8 @@
|
||||
"bleak-retry-connector==4.4.3",
|
||||
"bluetooth-adapters==2.1.0",
|
||||
"bluetooth-auto-recovery==1.5.3",
|
||||
"bluetooth-data-tools==1.28.3",
|
||||
"dbus-fast==2.44.5",
|
||||
"habluetooth==5.7.0"
|
||||
"bluetooth-data-tools==1.28.2",
|
||||
"dbus-fast==2.44.3",
|
||||
"habluetooth==5.6.4"
|
||||
]
|
||||
}
|
||||
|
@@ -7,14 +7,12 @@ from typing import Any
|
||||
from evolutionhttp import BryantEvolutionLocalClient
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ATTR_TARGET_TEMP_HIGH,
|
||||
ATTR_TARGET_TEMP_LOW,
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACAction,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
@@ -210,24 +208,24 @@ class BryantEvolutionClimate(ClimateEntity):
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature."""
|
||||
if value := kwargs.get(ATTR_TARGET_TEMP_HIGH):
|
||||
temp = int(value)
|
||||
if kwargs.get("target_temp_high"):
|
||||
temp = int(kwargs["target_temp_high"])
|
||||
if not await self._client.set_cooling_setpoint(temp):
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="failed_to_set_clsp"
|
||||
)
|
||||
self._attr_target_temperature_high = temp
|
||||
|
||||
if value := kwargs.get(ATTR_TARGET_TEMP_LOW):
|
||||
temp = int(value)
|
||||
if kwargs.get("target_temp_low"):
|
||||
temp = int(kwargs["target_temp_low"])
|
||||
if not await self._client.set_heating_setpoint(temp):
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="failed_to_set_htsp"
|
||||
)
|
||||
self._attr_target_temperature_low = temp
|
||||
|
||||
if value := kwargs.get(ATTR_TEMPERATURE):
|
||||
temp = int(value)
|
||||
if kwargs.get("temperature"):
|
||||
temp = int(kwargs["temperature"])
|
||||
fn = (
|
||||
self._client.set_heating_setpoint
|
||||
if self.hvac_mode == HVACMode.HEAT
|
||||
|
@@ -315,7 +315,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
hass.http.register_view(CalendarListView(component))
|
||||
hass.http.register_view(CalendarEventView(component))
|
||||
|
||||
frontend.async_register_built_in_panel(hass, "calendar", "calendar", "mdi:calendar")
|
||||
frontend.async_register_built_in_panel(
|
||||
hass, "calendar", "calendar", "hass:calendar"
|
||||
)
|
||||
|
||||
websocket_api.async_register_command(hass, handle_calendar_event_create)
|
||||
websocket_api.async_register_command(hass, handle_calendar_event_delete)
|
||||
|
@@ -169,7 +169,7 @@ class CalendarEventListener:
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
job: HassJob[..., Coroutine[Any, Any, None] | Any],
|
||||
job: HassJob[..., Coroutine[Any, Any, None]],
|
||||
trigger_data: dict[str, Any],
|
||||
fetcher: QueuedEventFetcher,
|
||||
) -> None:
|
||||
|
@@ -51,6 +51,12 @@ from homeassistant.const import (
|
||||
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
from homeassistant.helpers.deprecation import (
|
||||
DeprecatedConstantEnum,
|
||||
all_with_deprecated_constants,
|
||||
check_if_deprecated_constant,
|
||||
dir_with_deprecated_constants,
|
||||
)
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
@@ -112,6 +118,12 @@ ATTR_FILENAME: Final = "filename"
|
||||
ATTR_MEDIA_PLAYER: Final = "media_player"
|
||||
ATTR_FORMAT: Final = "format"
|
||||
|
||||
# These constants are deprecated as of Home Assistant 2024.10
|
||||
# Please use the StreamType enum instead.
|
||||
_DEPRECATED_STATE_RECORDING = DeprecatedConstantEnum(CameraState.RECORDING, "2025.10")
|
||||
_DEPRECATED_STATE_STREAMING = DeprecatedConstantEnum(CameraState.STREAMING, "2025.10")
|
||||
_DEPRECATED_STATE_IDLE = DeprecatedConstantEnum(CameraState.IDLE, "2025.10")
|
||||
|
||||
|
||||
class CameraEntityFeature(IntFlag):
|
||||
"""Supported features of the camera entity."""
|
||||
@@ -1105,3 +1117,11 @@ async def async_handle_record_service(
|
||||
duration=service_call.data[CONF_DURATION],
|
||||
lookback=service_call.data[CONF_LOOKBACK],
|
||||
)
|
||||
|
||||
|
||||
# These can be removed if no deprecated constant are in this module anymore
|
||||
__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
|
||||
__dir__ = partial(
|
||||
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
|
||||
)
|
||||
__all__ = all_with_deprecated_constants(globals())
|
||||
|
@@ -4,6 +4,5 @@
|
||||
"codeowners": [],
|
||||
"documentation": "https://www.home-assistant.io/integrations/citybikes",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["python-citybikes==0.3.3"]
|
||||
"quality_scale": "legacy"
|
||||
}
|
||||
|
@@ -5,11 +5,8 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import aiohttp
|
||||
from citybikes import __version__ as CITYBIKES_CLIENT_VERSION
|
||||
from citybikes.asyncio import Client as CitybikesClient
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
@@ -18,18 +15,21 @@ from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
APPLICATION_NAME,
|
||||
ATTR_ID,
|
||||
ATTR_LATITUDE,
|
||||
ATTR_LOCATION,
|
||||
ATTR_LONGITUDE,
|
||||
ATTR_NAME,
|
||||
CONF_LATITUDE,
|
||||
CONF_LONGITUDE,
|
||||
CONF_NAME,
|
||||
CONF_RADIUS,
|
||||
EVENT_HOMEASSISTANT_CLOSE,
|
||||
UnitOfLength,
|
||||
__version__,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import PlatformNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.entity import async_generate_entity_id
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
@@ -40,33 +40,31 @@ from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
HA_USER_AGENT = (
|
||||
f"{APPLICATION_NAME}/{__version__} "
|
||||
f"python-citybikes/{CITYBIKES_CLIENT_VERSION} "
|
||||
f"Python/{sys.version_info[0]}.{sys.version_info[1]}"
|
||||
)
|
||||
|
||||
ATTR_UID = "uid"
|
||||
ATTR_LATITUDE = "latitude"
|
||||
ATTR_LONGITUDE = "longitude"
|
||||
ATTR_EMPTY_SLOTS = "empty_slots"
|
||||
ATTR_EXTRA = "extra"
|
||||
ATTR_FREE_BIKES = "free_bikes"
|
||||
ATTR_NETWORK = "network"
|
||||
ATTR_NETWORKS_LIST = "networks"
|
||||
ATTR_STATIONS_LIST = "stations"
|
||||
ATTR_TIMESTAMP = "timestamp"
|
||||
ATTR_UID = "uid"
|
||||
|
||||
CONF_NETWORK = "network"
|
||||
CONF_STATIONS_LIST = "stations"
|
||||
|
||||
DEFAULT_ENDPOINT = "https://api.citybik.es/{uri}"
|
||||
PLATFORM = "citybikes"
|
||||
|
||||
MONITORED_NETWORKS = "monitored-networks"
|
||||
|
||||
DATA_CLIENT = "client"
|
||||
|
||||
NETWORKS_URI = "v2/networks"
|
||||
|
||||
REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=5)
|
||||
REQUEST_TIMEOUT = 5 # In seconds; argument to asyncio.timeout
|
||||
|
||||
SCAN_INTERVAL = timedelta(minutes=5) # Timely, and doesn't suffocate the API
|
||||
|
||||
STATIONS_URI = "v2/networks/{uid}?fields=network.stations"
|
||||
|
||||
CITYBIKES_ATTRIBUTION = (
|
||||
"Information provided by the CityBikes Project (https://citybik.es/#about)"
|
||||
)
|
||||
@@ -89,6 +87,72 @@ PLATFORM_SCHEMA = vol.All(
|
||||
),
|
||||
)
|
||||
|
||||
NETWORK_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_ID): cv.string,
|
||||
vol.Required(ATTR_NAME): cv.string,
|
||||
vol.Required(ATTR_LOCATION): vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_LATITUDE): cv.latitude,
|
||||
vol.Required(ATTR_LONGITUDE): cv.longitude,
|
||||
},
|
||||
extra=vol.REMOVE_EXTRA,
|
||||
),
|
||||
},
|
||||
extra=vol.REMOVE_EXTRA,
|
||||
)
|
||||
|
||||
NETWORKS_RESPONSE_SCHEMA = vol.Schema(
|
||||
{vol.Required(ATTR_NETWORKS_LIST): [NETWORK_SCHEMA]}
|
||||
)
|
||||
|
||||
STATION_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_FREE_BIKES): cv.positive_int,
|
||||
vol.Required(ATTR_EMPTY_SLOTS): vol.Any(cv.positive_int, None),
|
||||
vol.Required(ATTR_LATITUDE): cv.latitude,
|
||||
vol.Required(ATTR_LONGITUDE): cv.longitude,
|
||||
vol.Required(ATTR_ID): cv.string,
|
||||
vol.Required(ATTR_NAME): cv.string,
|
||||
vol.Required(ATTR_TIMESTAMP): cv.string,
|
||||
vol.Optional(ATTR_EXTRA): vol.Schema(
|
||||
{vol.Optional(ATTR_UID): cv.string}, extra=vol.REMOVE_EXTRA
|
||||
),
|
||||
},
|
||||
extra=vol.REMOVE_EXTRA,
|
||||
)
|
||||
|
||||
STATIONS_RESPONSE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_NETWORK): vol.Schema(
|
||||
{vol.Required(ATTR_STATIONS_LIST): [STATION_SCHEMA]}, extra=vol.REMOVE_EXTRA
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class CityBikesRequestError(Exception):
|
||||
"""Error to indicate a CityBikes API request has failed."""
|
||||
|
||||
|
||||
async def async_citybikes_request(hass, uri, schema):
|
||||
"""Perform a request to CityBikes API endpoint, and parse the response."""
|
||||
try:
|
||||
session = async_get_clientsession(hass)
|
||||
|
||||
async with asyncio.timeout(REQUEST_TIMEOUT):
|
||||
req = await session.get(DEFAULT_ENDPOINT.format(uri=uri))
|
||||
|
||||
json_response = await req.json()
|
||||
return schema(json_response)
|
||||
except (TimeoutError, aiohttp.ClientError):
|
||||
_LOGGER.error("Could not connect to CityBikes API endpoint")
|
||||
except ValueError:
|
||||
_LOGGER.error("Received non-JSON data from CityBikes API endpoint")
|
||||
except vol.Invalid as err:
|
||||
_LOGGER.error("Received unexpected JSON from CityBikes API endpoint: %s", err)
|
||||
raise CityBikesRequestError
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
hass: HomeAssistant,
|
||||
@@ -111,14 +175,6 @@ async def async_setup_platform(
|
||||
radius, UnitOfLength.FEET, UnitOfLength.METERS
|
||||
)
|
||||
|
||||
client = CitybikesClient(user_agent=HA_USER_AGENT, timeout=REQUEST_TIMEOUT)
|
||||
hass.data[PLATFORM][DATA_CLIENT] = client
|
||||
|
||||
async def _async_close_client(event):
|
||||
await client.close()
|
||||
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, _async_close_client)
|
||||
|
||||
# Create a single instance of CityBikesNetworks.
|
||||
networks = hass.data.setdefault(CITYBIKES_NETWORKS, CityBikesNetworks(hass))
|
||||
|
||||
@@ -138,10 +194,10 @@ async def async_setup_platform(
|
||||
devices = []
|
||||
for station in network.stations:
|
||||
dist = location_util.distance(
|
||||
latitude, longitude, station.latitude, station.longitude
|
||||
latitude, longitude, station[ATTR_LATITUDE], station[ATTR_LONGITUDE]
|
||||
)
|
||||
station_id = station.id
|
||||
station_uid = str(station.extra.get(ATTR_UID, ""))
|
||||
station_id = station[ATTR_ID]
|
||||
station_uid = str(station.get(ATTR_EXTRA, {}).get(ATTR_UID, ""))
|
||||
|
||||
if radius > dist or stations_list.intersection((station_id, station_uid)):
|
||||
if name:
|
||||
@@ -160,7 +216,6 @@ class CityBikesNetworks:
|
||||
def __init__(self, hass):
|
||||
"""Initialize the networks instance."""
|
||||
self.hass = hass
|
||||
self.client = hass.data[PLATFORM][DATA_CLIENT]
|
||||
self.networks = None
|
||||
self.networks_loading = asyncio.Condition()
|
||||
|
||||
@@ -169,21 +224,24 @@ class CityBikesNetworks:
|
||||
try:
|
||||
await self.networks_loading.acquire()
|
||||
if self.networks is None:
|
||||
self.networks = await self.client.networks.fetch()
|
||||
except aiohttp.ClientError as err:
|
||||
networks = await async_citybikes_request(
|
||||
self.hass, NETWORKS_URI, NETWORKS_RESPONSE_SCHEMA
|
||||
)
|
||||
self.networks = networks[ATTR_NETWORKS_LIST]
|
||||
except CityBikesRequestError as err:
|
||||
raise PlatformNotReady from err
|
||||
else:
|
||||
result = None
|
||||
minimum_dist = None
|
||||
for network in self.networks:
|
||||
network_latitude = network.location.latitude
|
||||
network_longitude = network.location.longitude
|
||||
network_latitude = network[ATTR_LOCATION][ATTR_LATITUDE]
|
||||
network_longitude = network[ATTR_LOCATION][ATTR_LONGITUDE]
|
||||
dist = location_util.distance(
|
||||
latitude, longitude, network_latitude, network_longitude
|
||||
)
|
||||
if minimum_dist is None or dist < minimum_dist:
|
||||
minimum_dist = dist
|
||||
result = network.id
|
||||
result = network[ATTR_ID]
|
||||
|
||||
return result
|
||||
finally:
|
||||
@@ -199,20 +257,22 @@ class CityBikesNetwork:
|
||||
self.network_id = network_id
|
||||
self.stations = []
|
||||
self.ready = asyncio.Event()
|
||||
self.client = hass.data[PLATFORM][DATA_CLIENT]
|
||||
|
||||
async def async_refresh(self, now=None):
|
||||
"""Refresh the state of the network."""
|
||||
try:
|
||||
network = await self.client.network(uid=self.network_id).fetch()
|
||||
except aiohttp.ClientError as err:
|
||||
if now is None:
|
||||
network = await async_citybikes_request(
|
||||
self.hass,
|
||||
STATIONS_URI.format(uid=self.network_id),
|
||||
STATIONS_RESPONSE_SCHEMA,
|
||||
)
|
||||
self.stations = network[ATTR_NETWORK][ATTR_STATIONS_LIST]
|
||||
self.ready.set()
|
||||
except CityBikesRequestError as err:
|
||||
if now is not None:
|
||||
self.ready.clear()
|
||||
else:
|
||||
raise PlatformNotReady from err
|
||||
self.ready.clear()
|
||||
return
|
||||
|
||||
self.stations = network.stations
|
||||
self.ready.set()
|
||||
|
||||
|
||||
class CityBikesStation(SensorEntity):
|
||||
@@ -230,13 +290,16 @@ class CityBikesStation(SensorEntity):
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update station state."""
|
||||
station = next(s for s in self._network.stations if s.id == self._station_id)
|
||||
self._attr_name = station.name
|
||||
self._attr_native_value = station.free_bikes
|
||||
for station in self._network.stations:
|
||||
if station[ATTR_ID] == self._station_id:
|
||||
station_data = station
|
||||
break
|
||||
self._attr_name = station_data.get(ATTR_NAME)
|
||||
self._attr_native_value = station_data.get(ATTR_FREE_BIKES)
|
||||
self._attr_extra_state_attributes = {
|
||||
ATTR_UID: station.extra.get(ATTR_UID),
|
||||
ATTR_LATITUDE: station.latitude,
|
||||
ATTR_LONGITUDE: station.longitude,
|
||||
ATTR_EMPTY_SLOTS: station.empty_slots,
|
||||
ATTR_TIMESTAMP: station.timestamp,
|
||||
ATTR_UID: station_data.get(ATTR_EXTRA, {}).get(ATTR_UID),
|
||||
ATTR_LATITUDE: station_data.get(ATTR_LATITUDE),
|
||||
ATTR_LONGITUDE: station_data.get(ATTR_LONGITUDE),
|
||||
ATTR_EMPTY_SLOTS: station_data.get(ATTR_EMPTY_SLOTS),
|
||||
ATTR_TIMESTAMP: station_data.get(ATTR_TIMESTAMP),
|
||||
}
|
||||
|
@@ -53,6 +53,7 @@ from .const import (
|
||||
CONF_ACME_SERVER,
|
||||
CONF_ALEXA,
|
||||
CONF_ALIASES,
|
||||
CONF_CLOUDHOOK_SERVER,
|
||||
CONF_COGNITO_CLIENT_ID,
|
||||
CONF_ENTITY_CONFIG,
|
||||
CONF_FILTER,
|
||||
@@ -129,6 +130,7 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
vol.Optional(CONF_ACCOUNT_LINK_SERVER): str,
|
||||
vol.Optional(CONF_ACCOUNTS_SERVER): str,
|
||||
vol.Optional(CONF_ACME_SERVER): str,
|
||||
vol.Optional(CONF_CLOUDHOOK_SERVER): str,
|
||||
vol.Optional(CONF_RELAYER_SERVER): str,
|
||||
vol.Optional(CONF_REMOTESTATE_SERVER): str,
|
||||
vol.Optional(CONF_SERVICEHANDLERS_SERVER): str,
|
||||
|
@@ -78,6 +78,7 @@ CONF_USER_POOL_ID = "user_pool_id"
|
||||
CONF_ACCOUNT_LINK_SERVER = "account_link_server"
|
||||
CONF_ACCOUNTS_SERVER = "accounts_server"
|
||||
CONF_ACME_SERVER = "acme_server"
|
||||
CONF_CLOUDHOOK_SERVER = "cloudhook_server"
|
||||
CONF_RELAYER_SERVER = "relayer_server"
|
||||
CONF_REMOTESTATE_SERVER = "remotestate_server"
|
||||
CONF_SERVICEHANDLERS_SERVER = "servicehandlers_server"
|
||||
|
@@ -13,6 +13,6 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["acme", "hass_nabucasa", "snitun"],
|
||||
"requirements": ["hass-nabucasa==1.2.0"],
|
||||
"requirements": ["hass-nabucasa==1.1.1"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
@@ -1,106 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
The integration does not provide any actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage:
|
||||
status: todo
|
||||
comment: |
|
||||
Stale docstring and test name: `test_form_home` and reusing result.
|
||||
Extract `async_setup_entry` into own fixture.
|
||||
Avoid importing `config_flow` in tests.
|
||||
Test reauth with errors
|
||||
config-flow:
|
||||
status: todo
|
||||
comment: |
|
||||
The config flow misses data descriptions.
|
||||
Remove URLs from data descriptions, they should be replaced with placeholders.
|
||||
Make use of Electricity Maps zone keys in country code as dropdown.
|
||||
Make use of location selector for coordinates.
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
The integration does not provide any actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
Entities of this integration do not explicitly subscribe to events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: todo
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: |
|
||||
The integration does not provide any actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: |
|
||||
The integration does not provide any additional options.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: done
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: |
|
||||
Use `hass.config_entries.async_setup` instead of assert await `async_setup_component(hass, DOMAIN, {})`
|
||||
`test_sensor` could use `snapshot_platform`
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration cannot be discovered, it is a connecting to a cloud service.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration cannot be discovered, it is a connecting to a cloud service.
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
The integration connects to a single service per configuration entry.
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not raise any repairable issues.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration connect to a single device per configuration entry.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: done
|
@@ -15,7 +15,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .coordinator import ComelitConfigEntry, ComelitVedoSystem
|
||||
from .utils import DeviceType, new_device_listener
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -30,19 +29,23 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
|
||||
|
||||
def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
|
||||
"""Add entities for new monitors."""
|
||||
entities = [
|
||||
ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data["alarm_zones"].values()
|
||||
if device in new_devices
|
||||
]
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
known_devices: set[int] = set()
|
||||
|
||||
config_entry.async_on_unload(
|
||||
new_device_listener(coordinator, _add_new_entities, "alarm_zones")
|
||||
)
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data["alarm_zones"])
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
ComelitVedoBinarySensorEntity(
|
||||
coordinator, device, config_entry.entry_id
|
||||
)
|
||||
for device in coordinator.data["alarm_zones"].values()
|
||||
if device.index in new_devices
|
||||
)
|
||||
|
||||
_check_device()
|
||||
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
|
||||
|
||||
class ComelitVedoBinarySensorEntity(
|
||||
|
@@ -7,14 +7,21 @@ from typing import Any, cast
|
||||
from aiocomelit import ComelitSerialBridgeObject
|
||||
from aiocomelit.const import COVER, STATE_COVER, STATE_OFF, STATE_ON
|
||||
|
||||
from homeassistant.components.cover import CoverDeviceClass, CoverEntity, CoverState
|
||||
from homeassistant.components.cover import (
|
||||
STATE_CLOSED,
|
||||
STATE_CLOSING,
|
||||
STATE_OPEN,
|
||||
STATE_OPENING,
|
||||
CoverDeviceClass,
|
||||
CoverEntity,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
from .coordinator import ComelitConfigEntry, ComelitSerialBridge
|
||||
from .entity import ComelitBridgeBaseEntity
|
||||
from .utils import DeviceType, bridge_api_call, new_device_listener
|
||||
from .utils import bridge_api_call
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -29,19 +36,21 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
|
||||
|
||||
def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
|
||||
"""Add entities for new monitors."""
|
||||
entities = [
|
||||
ComelitCoverEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data[dev_type].values()
|
||||
if device in new_devices
|
||||
]
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
known_devices: set[int] = set()
|
||||
|
||||
config_entry.async_on_unload(
|
||||
new_device_listener(coordinator, _add_new_entities, COVER)
|
||||
)
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data[COVER])
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
ComelitCoverEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data[COVER].values()
|
||||
if device.index in new_devices
|
||||
)
|
||||
|
||||
_check_device()
|
||||
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
|
||||
|
||||
class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity):
|
||||
@@ -121,9 +130,9 @@ class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity):
|
||||
await super().async_added_to_hass()
|
||||
|
||||
if (state := await self.async_get_last_state()) is not None:
|
||||
if state.state == CoverState.CLOSED:
|
||||
self._last_action = STATE_COVER.index(CoverState.CLOSING)
|
||||
if state.state == CoverState.OPEN:
|
||||
self._last_action = STATE_COVER.index(CoverState.OPENING)
|
||||
if state.state == STATE_CLOSED:
|
||||
self._last_action = STATE_COVER.index(STATE_CLOSING)
|
||||
if state.state == STATE_OPEN:
|
||||
self._last_action = STATE_COVER.index(STATE_OPENING)
|
||||
|
||||
self._attr_is_closed = state.state == CoverState.CLOSED
|
||||
self._attr_is_closed = state.state == STATE_CLOSED
|
||||
|
@@ -12,7 +12,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import ComelitConfigEntry, ComelitSerialBridge
|
||||
from .entity import ComelitBridgeBaseEntity
|
||||
from .utils import DeviceType, bridge_api_call, new_device_listener
|
||||
from .utils import bridge_api_call
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -27,19 +27,21 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
|
||||
|
||||
def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
|
||||
"""Add entities for new monitors."""
|
||||
entities = [
|
||||
ComelitLightEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data[dev_type].values()
|
||||
if device in new_devices
|
||||
]
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
known_devices: set[int] = set()
|
||||
|
||||
config_entry.async_on_unload(
|
||||
new_device_listener(coordinator, _add_new_entities, LIGHT)
|
||||
)
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data[LIGHT])
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
ComelitLightEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data[LIGHT].values()
|
||||
if device.index in new_devices
|
||||
)
|
||||
|
||||
_check_device()
|
||||
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
|
||||
|
||||
class ComelitLightEntity(ComelitBridgeBaseEntity, LightEntity):
|
||||
|
@@ -20,7 +20,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .coordinator import ComelitConfigEntry, ComelitSerialBridge, ComelitVedoSystem
|
||||
from .entity import ComelitBridgeBaseEntity
|
||||
from .utils import DeviceType, new_device_listener
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -66,22 +65,24 @@ async def async_setup_bridge_entry(
|
||||
|
||||
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
|
||||
|
||||
def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
|
||||
"""Add entities for new monitors."""
|
||||
entities = [
|
||||
ComelitBridgeSensorEntity(
|
||||
coordinator, device, config_entry.entry_id, sensor_desc
|
||||
)
|
||||
for sensor_desc in SENSOR_BRIDGE_TYPES
|
||||
for device in coordinator.data[dev_type].values()
|
||||
if device in new_devices
|
||||
]
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
known_devices: set[int] = set()
|
||||
|
||||
config_entry.async_on_unload(
|
||||
new_device_listener(coordinator, _add_new_entities, OTHER)
|
||||
)
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data[OTHER])
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
ComelitBridgeSensorEntity(
|
||||
coordinator, device, config_entry.entry_id, sensor_desc
|
||||
)
|
||||
for sensor_desc in SENSOR_BRIDGE_TYPES
|
||||
for device in coordinator.data[OTHER].values()
|
||||
if device.index in new_devices
|
||||
)
|
||||
|
||||
_check_device()
|
||||
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
|
||||
|
||||
async def async_setup_vedo_entry(
|
||||
@@ -93,22 +94,24 @@ async def async_setup_vedo_entry(
|
||||
|
||||
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
|
||||
|
||||
def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
|
||||
"""Add entities for new monitors."""
|
||||
entities = [
|
||||
ComelitVedoSensorEntity(
|
||||
coordinator, device, config_entry.entry_id, sensor_desc
|
||||
)
|
||||
for sensor_desc in SENSOR_VEDO_TYPES
|
||||
for device in coordinator.data["alarm_zones"].values()
|
||||
if device in new_devices
|
||||
]
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
known_devices: set[int] = set()
|
||||
|
||||
config_entry.async_on_unload(
|
||||
new_device_listener(coordinator, _add_new_entities, "alarm_zones")
|
||||
)
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data["alarm_zones"])
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
ComelitVedoSensorEntity(
|
||||
coordinator, device, config_entry.entry_id, sensor_desc
|
||||
)
|
||||
for sensor_desc in SENSOR_VEDO_TYPES
|
||||
for device in coordinator.data["alarm_zones"].values()
|
||||
if device.index in new_devices
|
||||
)
|
||||
|
||||
_check_device()
|
||||
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
|
||||
|
||||
class ComelitBridgeSensorEntity(ComelitBridgeBaseEntity, SensorEntity):
|
||||
|
@@ -13,7 +13,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import ComelitConfigEntry, ComelitSerialBridge
|
||||
from .entity import ComelitBridgeBaseEntity
|
||||
from .utils import DeviceType, bridge_api_call, new_device_listener
|
||||
from .utils import bridge_api_call
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -28,20 +28,35 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
|
||||
|
||||
def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
|
||||
"""Add entities for new monitors."""
|
||||
entities = [
|
||||
ComelitSwitchEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data[dev_type].values()
|
||||
if device in new_devices
|
||||
]
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
entities: list[ComelitSwitchEntity] = []
|
||||
entities.extend(
|
||||
ComelitSwitchEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data[IRRIGATION].values()
|
||||
)
|
||||
entities.extend(
|
||||
ComelitSwitchEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data[OTHER].values()
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
for dev_type in (IRRIGATION, OTHER):
|
||||
config_entry.async_on_unload(
|
||||
new_device_listener(coordinator, _add_new_entities, dev_type)
|
||||
)
|
||||
known_devices: dict[str, set[int]] = {
|
||||
dev_type: set() for dev_type in (IRRIGATION, OTHER)
|
||||
}
|
||||
|
||||
def _check_device() -> None:
|
||||
for dev_type in (IRRIGATION, OTHER):
|
||||
current_devices = set(coordinator.data[dev_type])
|
||||
new_devices = current_devices - known_devices[dev_type]
|
||||
if new_devices:
|
||||
known_devices[dev_type].update(new_devices)
|
||||
async_add_entities(
|
||||
ComelitSwitchEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data[dev_type].values()
|
||||
if device.index in new_devices
|
||||
)
|
||||
|
||||
_check_device()
|
||||
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
|
||||
|
||||
class ComelitSwitchEntity(ComelitBridgeBaseEntity, SwitchEntity):
|
||||
|
@@ -4,11 +4,7 @@ from collections.abc import Awaitable, Callable, Coroutine
|
||||
from functools import wraps
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from aiocomelit.api import (
|
||||
ComelitSerialBridgeObject,
|
||||
ComelitVedoAreaObject,
|
||||
ComelitVedoZoneObject,
|
||||
)
|
||||
from aiocomelit import ComelitSerialBridgeObject
|
||||
from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData
|
||||
from aiohttp import ClientSession, CookieJar
|
||||
|
||||
@@ -23,11 +19,8 @@ from homeassistant.helpers import (
|
||||
)
|
||||
|
||||
from .const import _LOGGER, DOMAIN
|
||||
from .coordinator import ComelitBaseCoordinator
|
||||
from .entity import ComelitBridgeBaseEntity
|
||||
|
||||
DeviceType = ComelitSerialBridgeObject | ComelitVedoAreaObject | ComelitVedoZoneObject
|
||||
|
||||
|
||||
async def async_client_session(hass: HomeAssistant) -> ClientSession:
|
||||
"""Return a new aiohttp session."""
|
||||
@@ -120,41 +113,3 @@ def bridge_api_call[_T: ComelitBridgeBaseEntity, **_P](
|
||||
self.coordinator.config_entry.async_start_reauth(self.hass)
|
||||
|
||||
return cmd_wrapper
|
||||
|
||||
|
||||
def new_device_listener(
|
||||
coordinator: ComelitBaseCoordinator,
|
||||
new_devices_callback: Callable[
|
||||
[
|
||||
list[
|
||||
ComelitSerialBridgeObject
|
||||
| ComelitVedoAreaObject
|
||||
| ComelitVedoZoneObject
|
||||
],
|
||||
str,
|
||||
],
|
||||
None,
|
||||
],
|
||||
data_type: str,
|
||||
) -> Callable[[], None]:
|
||||
"""Subscribe to coordinator updates to check for new devices."""
|
||||
known_devices: set[int] = set()
|
||||
|
||||
def _check_devices() -> None:
|
||||
"""Check for new devices and call callback with any new monitors."""
|
||||
if not coordinator.data:
|
||||
return
|
||||
|
||||
new_devices: list[DeviceType] = []
|
||||
for _id in coordinator.data[data_type]:
|
||||
if _id not in known_devices:
|
||||
known_devices.add(_id)
|
||||
new_devices.append(coordinator.data[data_type][_id])
|
||||
|
||||
if new_devices:
|
||||
new_devices_callback(new_devices, data_type)
|
||||
|
||||
# Check for devices immediately
|
||||
_check_devices()
|
||||
|
||||
return coordinator.async_add_listener(_check_devices)
|
||||
|
@@ -49,7 +49,7 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the config component."""
|
||||
frontend.async_register_built_in_panel(
|
||||
hass, "config", "config", "mdi:cog", require_admin=True
|
||||
hass, "config", "config", "hass:cog", require_admin=True
|
||||
)
|
||||
|
||||
for panel in SECTIONS:
|
||||
|
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
from typing import Any, NoReturn
|
||||
|
||||
from aiohttp import web
|
||||
@@ -24,12 +23,7 @@ from homeassistant.helpers.data_entry_flow import (
|
||||
FlowManagerResourceView,
|
||||
)
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.json import (
|
||||
JSON_DUMP,
|
||||
find_paths_unserializable_data,
|
||||
json_bytes,
|
||||
json_fragment,
|
||||
)
|
||||
from homeassistant.helpers.json import json_fragment
|
||||
from homeassistant.loader import (
|
||||
Integration,
|
||||
IntegrationNotFound,
|
||||
@@ -37,9 +31,6 @@ from homeassistant.loader import (
|
||||
async_get_integrations,
|
||||
async_get_loaded_integration,
|
||||
)
|
||||
from homeassistant.util.json import format_unserializable_data
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@callback
|
||||
@@ -411,40 +402,18 @@ def config_entries_flow_subscribe(
|
||||
connection.subscriptions[msg["id"]] = hass.config_entries.flow.async_subscribe_flow(
|
||||
async_on_flow_init_remove
|
||||
)
|
||||
try:
|
||||
serialized_flows = [
|
||||
json_bytes({"type": None, "flow_id": flw["flow_id"], "flow": flw})
|
||||
for flw in hass.config_entries.flow.async_progress()
|
||||
if flw["context"]["source"]
|
||||
not in (
|
||||
config_entries.SOURCE_RECONFIGURE,
|
||||
config_entries.SOURCE_USER,
|
||||
)
|
||||
]
|
||||
except (ValueError, TypeError):
|
||||
# If we can't serialize, we'll filter out unserializable flows
|
||||
serialized_flows = []
|
||||
for flw in hass.config_entries.flow.async_progress():
|
||||
if flw["context"]["source"] in (
|
||||
config_entries.SOURCE_RECONFIGURE,
|
||||
config_entries.SOURCE_USER,
|
||||
):
|
||||
continue
|
||||
try:
|
||||
serialized_flows.append(
|
||||
json_bytes({"type": None, "flow_id": flw["flow_id"], "flow": flw})
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
_LOGGER.error(
|
||||
"Unable to serialize to JSON. Bad data found at %s",
|
||||
format_unserializable_data(
|
||||
find_paths_unserializable_data(flw, dump=JSON_DUMP)
|
||||
),
|
||||
)
|
||||
continue
|
||||
connection.send_message(
|
||||
websocket_api.messages.construct_event_message(
|
||||
msg["id"], b"".join((b"[", b",".join(serialized_flows), b"]"))
|
||||
websocket_api.event_message(
|
||||
msg["id"],
|
||||
[
|
||||
{"type": None, "flow_id": flw["flow_id"], "flow": flw}
|
||||
for flw in hass.config_entries.flow.async_progress()
|
||||
if flw["context"]["source"]
|
||||
not in (
|
||||
config_entries.SOURCE_RECONFIGURE,
|
||||
config_entries.SOURCE_USER,
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
connection.send_result(msg["id"])
|
||||
|
@@ -20,13 +20,10 @@ from homeassistant.util.hass_dict import HassKey
|
||||
from homeassistant.util.json import JsonObjectType
|
||||
|
||||
from . import trace
|
||||
from .const import ChatLogEventType
|
||||
from .models import ConversationInput, ConversationResult
|
||||
|
||||
DATA_CHAT_LOGS: HassKey[dict[str, ChatLog]] = HassKey("conversation_chat_logs")
|
||||
SUBSCRIPTIONS: HassKey[list[Callable[[ChatLogEventType, dict[str, Any]], None]]] = (
|
||||
HassKey("conversation_chat_log_subscriptions")
|
||||
)
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
current_chat_log: ContextVar[ChatLog | None] = ContextVar(
|
||||
@@ -34,37 +31,6 @@ current_chat_log: ContextVar[ChatLog | None] = ContextVar(
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_subscribe_chat_logs(
|
||||
hass: HomeAssistant,
|
||||
callback_func: Callable[[ChatLogEventType, dict[str, Any]], None],
|
||||
) -> Callable[[], None]:
|
||||
"""Subscribe to all chat logs."""
|
||||
subscriptions = hass.data.get(SUBSCRIPTIONS)
|
||||
if subscriptions is None:
|
||||
subscriptions = []
|
||||
hass.data[SUBSCRIPTIONS] = subscriptions
|
||||
|
||||
subscriptions.append(callback_func)
|
||||
|
||||
@callback
|
||||
def unsubscribe() -> None:
|
||||
"""Unsubscribe from chat logs."""
|
||||
subscriptions.remove(callback_func)
|
||||
|
||||
return unsubscribe
|
||||
|
||||
|
||||
@callback
|
||||
def _async_notify_subscribers(
|
||||
hass: HomeAssistant, event_type: ChatLogEventType, data: dict[str, Any]
|
||||
) -> None:
|
||||
"""Notify subscribers of a chat log event."""
|
||||
if subscriptions := hass.data.get(SUBSCRIPTIONS):
|
||||
for callback_func in subscriptions:
|
||||
callback_func(event_type, data)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def async_get_chat_log(
|
||||
hass: HomeAssistant,
|
||||
@@ -97,8 +63,6 @@ def async_get_chat_log(
|
||||
all_chat_logs = {}
|
||||
hass.data[DATA_CHAT_LOGS] = all_chat_logs
|
||||
|
||||
is_new_log = session.conversation_id not in all_chat_logs
|
||||
|
||||
if chat_log := all_chat_logs.get(session.conversation_id):
|
||||
chat_log = replace(chat_log, content=chat_log.content.copy())
|
||||
else:
|
||||
@@ -107,12 +71,6 @@ def async_get_chat_log(
|
||||
if chat_log_delta_listener:
|
||||
chat_log.delta_listener = chat_log_delta_listener
|
||||
|
||||
# Fire CREATED event for new chat logs before any content is added
|
||||
if is_new_log:
|
||||
_async_notify_subscribers(
|
||||
hass, ChatLogEventType.CREATED, {"chat_log": chat_log.as_dict()}
|
||||
)
|
||||
|
||||
if user_input is not None:
|
||||
chat_log.async_add_user_content(UserContent(content=user_input.text))
|
||||
|
||||
@@ -126,26 +84,14 @@ def async_get_chat_log(
|
||||
LOGGER.debug(
|
||||
"Chat Log opened but no assistant message was added, ignoring update"
|
||||
)
|
||||
# If this was a new log but nothing was added, fire DELETED to clean up
|
||||
if is_new_log:
|
||||
_async_notify_subscribers(
|
||||
hass,
|
||||
ChatLogEventType.DELETED,
|
||||
{"conversation_id": session.conversation_id},
|
||||
)
|
||||
return
|
||||
|
||||
if is_new_log:
|
||||
if session.conversation_id not in all_chat_logs:
|
||||
|
||||
@callback
|
||||
def do_cleanup() -> None:
|
||||
"""Handle cleanup."""
|
||||
all_chat_logs.pop(session.conversation_id)
|
||||
_async_notify_subscribers(
|
||||
hass,
|
||||
ChatLogEventType.DELETED,
|
||||
{"conversation_id": session.conversation_id},
|
||||
)
|
||||
|
||||
session.async_on_cleanup(do_cleanup)
|
||||
|
||||
@@ -154,13 +100,6 @@ def async_get_chat_log(
|
||||
|
||||
all_chat_logs[session.conversation_id] = chat_log
|
||||
|
||||
# For new logs, CREATED was already fired before content was added
|
||||
# For existing logs, fire UPDATED
|
||||
if not is_new_log:
|
||||
_async_notify_subscribers(
|
||||
hass, ChatLogEventType.UPDATED, {"chat_log": chat_log.as_dict()}
|
||||
)
|
||||
|
||||
|
||||
class ConverseError(HomeAssistantError):
|
||||
"""Error during initialization of conversation.
|
||||
@@ -191,10 +130,6 @@ class SystemContent:
|
||||
role: Literal["system"] = field(init=False, default="system")
|
||||
content: str
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the content."""
|
||||
return {"role": self.role, "content": self.content}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UserContent:
|
||||
@@ -204,15 +139,6 @@ class UserContent:
|
||||
content: str
|
||||
attachments: list[Attachment] | None = field(default=None)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the content."""
|
||||
result: dict[str, Any] = {"role": self.role, "content": self.content}
|
||||
if self.attachments:
|
||||
result["attachments"] = [
|
||||
attachment.as_dict() for attachment in self.attachments
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Attachment:
|
||||
@@ -227,14 +153,6 @@ class Attachment:
|
||||
path: Path
|
||||
"""Path to the attachment on disk."""
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the attachment."""
|
||||
return {
|
||||
"media_content_id": self.media_content_id,
|
||||
"mime_type": self.mime_type,
|
||||
"path": str(self.path),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssistantContent:
|
||||
@@ -247,17 +165,6 @@ class AssistantContent:
|
||||
tool_calls: list[llm.ToolInput] | None = None
|
||||
native: Any = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the content."""
|
||||
result: dict[str, Any] = {"role": self.role, "agent_id": self.agent_id}
|
||||
if self.content:
|
||||
result["content"] = self.content
|
||||
if self.thinking_content:
|
||||
result["thinking_content"] = self.thinking_content
|
||||
if self.tool_calls:
|
||||
result["tool_calls"] = self.tool_calls
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolResultContent:
|
||||
@@ -269,16 +176,6 @@ class ToolResultContent:
|
||||
tool_name: str
|
||||
tool_result: JsonObjectType
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the content."""
|
||||
return {
|
||||
"role": self.role,
|
||||
"agent_id": self.agent_id,
|
||||
"tool_call_id": self.tool_call_id,
|
||||
"tool_name": self.tool_name,
|
||||
"tool_result": self.tool_result,
|
||||
}
|
||||
|
||||
|
||||
type Content = SystemContent | UserContent | AssistantContent | ToolResultContent
|
||||
|
||||
@@ -314,13 +211,6 @@ class ChatLog:
|
||||
delta_listener: Callable[[ChatLog, dict], None] | None = None
|
||||
llm_input_provided_index = 0
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the chat log."""
|
||||
return {
|
||||
"conversation_id": self.conversation_id,
|
||||
"continue_conversation": self.continue_conversation,
|
||||
}
|
||||
|
||||
@property
|
||||
def continue_conversation(self) -> bool:
|
||||
"""Return whether the conversation should continue."""
|
||||
@@ -351,11 +241,6 @@ class ChatLog:
|
||||
"""Add user content to the log."""
|
||||
LOGGER.debug("Adding user content: %s", content)
|
||||
self.content.append(content)
|
||||
_async_notify_subscribers(
|
||||
self.hass,
|
||||
ChatLogEventType.CONTENT_ADDED,
|
||||
{"conversation_id": self.conversation_id, "content": content.as_dict()},
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_add_assistant_content_without_tools(
|
||||
@@ -374,11 +259,6 @@ class ChatLog:
|
||||
):
|
||||
raise ValueError("Non-external tool calls not allowed")
|
||||
self.content.append(content)
|
||||
_async_notify_subscribers(
|
||||
self.hass,
|
||||
ChatLogEventType.CONTENT_ADDED,
|
||||
{"conversation_id": self.conversation_id, "content": content.as_dict()},
|
||||
)
|
||||
|
||||
async def async_add_assistant_content(
|
||||
self,
|
||||
@@ -437,14 +317,6 @@ class ChatLog:
|
||||
tool_result=tool_result,
|
||||
)
|
||||
self.content.append(response_content)
|
||||
_async_notify_subscribers(
|
||||
self.hass,
|
||||
ChatLogEventType.CONTENT_ADDED,
|
||||
{
|
||||
"conversation_id": self.conversation_id,
|
||||
"content": response_content.as_dict(),
|
||||
},
|
||||
)
|
||||
yield response_content
|
||||
|
||||
async def async_add_delta_content_stream(
|
||||
@@ -718,11 +590,6 @@ class ChatLog:
|
||||
self.llm_api = llm_api
|
||||
self.extra_system_prompt = extra_system_prompt
|
||||
self.content[0] = SystemContent(content=prompt)
|
||||
_async_notify_subscribers(
|
||||
self.hass,
|
||||
ChatLogEventType.UPDATED,
|
||||
{"conversation_id": self.conversation_id, "chat_log": self.as_dict()},
|
||||
)
|
||||
|
||||
LOGGER.debug("Prompt: %s", self.content)
|
||||
LOGGER.debug("Tools: %s", self.llm_api.tools if self.llm_api else None)
|
||||
|
@@ -26,19 +26,7 @@ SERVICE_RELOAD = "reload"
|
||||
DATA_COMPONENT: HassKey[EntityComponent[ConversationEntity]] = HassKey(DOMAIN)
|
||||
|
||||
|
||||
from homeassistant.const import StrEnum
|
||||
|
||||
|
||||
class ConversationEntityFeature(IntFlag):
|
||||
"""Supported features of the conversation entity."""
|
||||
|
||||
CONTROL = 1
|
||||
|
||||
|
||||
class ChatLogEventType(StrEnum):
|
||||
"""Chat log event type."""
|
||||
|
||||
CREATED = "created"
|
||||
UPDATED = "updated"
|
||||
DELETED = "deleted"
|
||||
CONTENT_ADDED = "content_added"
|
||||
|
@@ -38,30 +38,22 @@ from home_assistant_intents import (
|
||||
ErrorKey,
|
||||
FuzzyConfig,
|
||||
FuzzyLanguageResponses,
|
||||
LanguageScores,
|
||||
get_fuzzy_config,
|
||||
get_fuzzy_language,
|
||||
get_intents,
|
||||
get_language_scores,
|
||||
get_languages,
|
||||
)
|
||||
import yaml
|
||||
|
||||
from homeassistant import core
|
||||
from homeassistant.components.homeassistant.exposed_entities import (
|
||||
async_listen_entity_updates,
|
||||
async_should_expose,
|
||||
)
|
||||
from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL
|
||||
from homeassistant.core import (
|
||||
Event,
|
||||
EventStateChangedData,
|
||||
HomeAssistant,
|
||||
State,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.core import Event, callback
|
||||
from homeassistant.helpers import (
|
||||
area_registry as ar,
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
floor_registry as fr,
|
||||
@@ -200,7 +192,7 @@ class IntentCache:
|
||||
|
||||
|
||||
async def async_setup_default_agent(
|
||||
hass: HomeAssistant,
|
||||
hass: core.HomeAssistant,
|
||||
entity_component: EntityComponent[ConversationEntity],
|
||||
config_intents: dict[str, Any],
|
||||
) -> None:
|
||||
@@ -209,13 +201,15 @@ async def async_setup_default_agent(
|
||||
await entity_component.async_add_entities([agent])
|
||||
await get_agent_manager(hass).async_setup_default_agent(agent)
|
||||
|
||||
@callback
|
||||
def async_entity_state_listener(event: Event[EventStateChangedData]) -> None:
|
||||
@core.callback
|
||||
def async_entity_state_listener(
|
||||
event: core.Event[core.EventStateChangedData],
|
||||
) -> None:
|
||||
"""Set expose flag on new entities."""
|
||||
async_should_expose(hass, DOMAIN, event.data["entity_id"])
|
||||
|
||||
@callback
|
||||
def async_hass_started(hass: HomeAssistant) -> None:
|
||||
@core.callback
|
||||
def async_hass_started(hass: core.HomeAssistant) -> None:
|
||||
"""Set expose flag on all entities."""
|
||||
for state in hass.states.async_all():
|
||||
async_should_expose(hass, DOMAIN, state.entity_id)
|
||||
@@ -230,7 +224,9 @@ class DefaultAgent(ConversationEntity):
|
||||
_attr_name = "Home Assistant"
|
||||
_attr_supported_features = ConversationEntityFeature.CONTROL
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config_intents: dict[str, Any]) -> None:
|
||||
def __init__(
|
||||
self, hass: core.HomeAssistant, config_intents: dict[str, Any]
|
||||
) -> None:
|
||||
"""Initialize the default agent."""
|
||||
self.hass = hass
|
||||
self._lang_intents: dict[str, LanguageIntents | object] = {}
|
||||
@@ -263,7 +259,7 @@ class DefaultAgent(ConversationEntity):
|
||||
"""Return a list of supported languages."""
|
||||
return get_languages()
|
||||
|
||||
@callback
|
||||
@core.callback
|
||||
def _filter_entity_registry_changes(
|
||||
self, event_data: er.EventEntityRegistryUpdatedData
|
||||
) -> bool:
|
||||
@@ -272,12 +268,12 @@ class DefaultAgent(ConversationEntity):
|
||||
field in event_data["changes"] for field in _ENTITY_REGISTRY_UPDATE_FIELDS
|
||||
)
|
||||
|
||||
@callback
|
||||
def _filter_state_changes(self, event_data: EventStateChangedData) -> bool:
|
||||
@core.callback
|
||||
def _filter_state_changes(self, event_data: core.EventStateChangedData) -> bool:
|
||||
"""Filter state changed events."""
|
||||
return not event_data["old_state"] or not event_data["new_state"]
|
||||
|
||||
@callback
|
||||
@core.callback
|
||||
def _listen_clear_slot_list(self) -> None:
|
||||
"""Listen for changes that can invalidate slot list."""
|
||||
assert self._unsub_clear_slot_list is None
|
||||
@@ -346,81 +342,6 @@ class DefaultAgent(ConversationEntity):
|
||||
|
||||
return result
|
||||
|
||||
async def async_debug_recognize(
|
||||
self, user_input: ConversationInput
|
||||
) -> dict[str, Any] | None:
|
||||
"""Debug recognize from user input."""
|
||||
result_dict: dict[str, Any] | None = None
|
||||
|
||||
if trigger_result := await self.async_recognize_sentence_trigger(user_input):
|
||||
result_dict = {
|
||||
# Matched a user-defined sentence trigger.
|
||||
# We can't provide the response here without executing the
|
||||
# trigger.
|
||||
"match": True,
|
||||
"source": "trigger",
|
||||
"sentence_template": trigger_result.sentence_template or "",
|
||||
}
|
||||
elif intent_result := await self.async_recognize_intent(user_input):
|
||||
successful_match = not intent_result.unmatched_entities
|
||||
result_dict = {
|
||||
# Name of the matching intent (or the closest)
|
||||
"intent": {
|
||||
"name": intent_result.intent.name,
|
||||
},
|
||||
# Slot values that would be received by the intent
|
||||
"slots": { # direct access to values
|
||||
entity_key: entity.text or entity.value
|
||||
for entity_key, entity in intent_result.entities.items()
|
||||
},
|
||||
# Extra slot details, such as the originally matched text
|
||||
"details": {
|
||||
entity_key: {
|
||||
"name": entity.name,
|
||||
"value": entity.value,
|
||||
"text": entity.text,
|
||||
}
|
||||
for entity_key, entity in intent_result.entities.items()
|
||||
},
|
||||
# Entities/areas/etc. that would be targeted
|
||||
"targets": {},
|
||||
# True if match was successful
|
||||
"match": successful_match,
|
||||
# Text of the sentence template that matched (or was closest)
|
||||
"sentence_template": "",
|
||||
# When match is incomplete, this will contain the best slot guesses
|
||||
"unmatched_slots": _get_unmatched_slots(intent_result),
|
||||
# True if match was not exact
|
||||
"fuzzy_match": False,
|
||||
}
|
||||
|
||||
if successful_match:
|
||||
result_dict["targets"] = {
|
||||
state.entity_id: {"matched": is_matched}
|
||||
for state, is_matched in _get_debug_targets(
|
||||
self.hass, intent_result
|
||||
)
|
||||
}
|
||||
|
||||
if intent_result.intent_sentence is not None:
|
||||
result_dict["sentence_template"] = intent_result.intent_sentence.text
|
||||
|
||||
if intent_result.intent_metadata:
|
||||
# Inspect metadata to determine if this matched a custom sentence
|
||||
if intent_result.intent_metadata.get(METADATA_CUSTOM_SENTENCE):
|
||||
result_dict["source"] = "custom"
|
||||
result_dict["file"] = intent_result.intent_metadata.get(
|
||||
METADATA_CUSTOM_FILE
|
||||
)
|
||||
else:
|
||||
result_dict["source"] = "builtin"
|
||||
|
||||
result_dict["fuzzy_match"] = intent_result.intent_metadata.get(
|
||||
METADATA_FUZZY_MATCH, False
|
||||
)
|
||||
|
||||
return result_dict
|
||||
|
||||
async def _async_handle_message(
|
||||
self,
|
||||
user_input: ConversationInput,
|
||||
@@ -969,7 +890,7 @@ class DefaultAgent(ConversationEntity):
|
||||
) -> str:
|
||||
# Get first matched or unmatched state.
|
||||
# This is available in the response template as "state".
|
||||
state1: State | None = None
|
||||
state1: core.State | None = None
|
||||
if intent_response.matched_states:
|
||||
state1 = intent_response.matched_states[0]
|
||||
elif intent_response.unmatched_states:
|
||||
@@ -1607,10 +1528,6 @@ class DefaultAgent(ConversationEntity):
|
||||
return None
|
||||
return response
|
||||
|
||||
async def async_get_language_scores(self) -> dict[str, LanguageScores]:
|
||||
"""Get support scores per language."""
|
||||
return await self.hass.async_add_executor_job(get_language_scores)
|
||||
|
||||
|
||||
def _make_error_result(
|
||||
language: str,
|
||||
@@ -1672,7 +1589,7 @@ def _get_unmatched_response(result: RecognizeResult) -> tuple[ErrorKey, dict[str
|
||||
|
||||
|
||||
def _get_match_error_response(
|
||||
hass: HomeAssistant,
|
||||
hass: core.HomeAssistant,
|
||||
match_error: intent.MatchFailedError,
|
||||
) -> tuple[ErrorKey, dict[str, Any]]:
|
||||
"""Return key and template arguments for error when target matching fails."""
|
||||
@@ -1807,75 +1724,3 @@ def _collect_list_references(expression: Expression, list_names: set[str]) -> No
|
||||
elif isinstance(expression, ListReference):
|
||||
# {list}
|
||||
list_names.add(expression.slot_name)
|
||||
|
||||
|
||||
def _get_debug_targets(
|
||||
hass: HomeAssistant,
|
||||
result: RecognizeResult,
|
||||
) -> Iterable[tuple[State, bool]]:
|
||||
"""Yield state/is_matched pairs for a hassil recognition."""
|
||||
entities = result.entities
|
||||
|
||||
name: str | None = None
|
||||
area_name: str | None = None
|
||||
domains: set[str] | None = None
|
||||
device_classes: set[str] | None = None
|
||||
state_names: set[str] | None = None
|
||||
|
||||
if "name" in entities:
|
||||
name = str(entities["name"].value)
|
||||
|
||||
if "area" in entities:
|
||||
area_name = str(entities["area"].value)
|
||||
|
||||
if "domain" in entities:
|
||||
domains = set(cv.ensure_list(entities["domain"].value))
|
||||
|
||||
if "device_class" in entities:
|
||||
device_classes = set(cv.ensure_list(entities["device_class"].value))
|
||||
|
||||
if "state" in entities:
|
||||
# HassGetState only
|
||||
state_names = set(cv.ensure_list(entities["state"].value))
|
||||
|
||||
if (
|
||||
(name is None)
|
||||
and (area_name is None)
|
||||
and (not domains)
|
||||
and (not device_classes)
|
||||
and (not state_names)
|
||||
):
|
||||
# Avoid "matching" all entities when there is no filter
|
||||
return
|
||||
|
||||
states = intent.async_match_states(
|
||||
hass,
|
||||
name=name,
|
||||
area_name=area_name,
|
||||
domains=domains,
|
||||
device_classes=device_classes,
|
||||
)
|
||||
|
||||
for state in states:
|
||||
# For queries, a target is "matched" based on its state
|
||||
is_matched = (state_names is None) or (state.state in state_names)
|
||||
yield state, is_matched
|
||||
|
||||
|
||||
def _get_unmatched_slots(
|
||||
result: RecognizeResult,
|
||||
) -> dict[str, str | int | float]:
|
||||
"""Return a dict of unmatched text/range slot entities."""
|
||||
unmatched_slots: dict[str, str | int | float] = {}
|
||||
for entity in result.unmatched_entities_list:
|
||||
if isinstance(entity, UnmatchedTextEntity):
|
||||
if entity.text == MISSING_ENTITY:
|
||||
# Don't report <missing> since these are just missing context
|
||||
# slots.
|
||||
continue
|
||||
|
||||
unmatched_slots[entity.name] = entity.text
|
||||
elif isinstance(entity, UnmatchedRangeEntity):
|
||||
unmatched_slots[entity.name] = entity.value
|
||||
|
||||
return unmatched_slots
|
||||
|
@@ -2,16 +2,21 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
from hassil.recognize import MISSING_ENTITY, RecognizeResult
|
||||
from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity
|
||||
from home_assistant_intents import get_language_scores
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import http, websocket_api
|
||||
from homeassistant.components.http.data_validator import RequestDataValidator
|
||||
from homeassistant.const import MATCH_ALL
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant, State, callback
|
||||
from homeassistant.helpers import config_validation as cv, intent
|
||||
from homeassistant.util import language as language_util
|
||||
|
||||
from .agent_manager import (
|
||||
@@ -20,8 +25,12 @@ from .agent_manager import (
|
||||
async_get_agent,
|
||||
get_agent_manager,
|
||||
)
|
||||
from .chat_log import async_subscribe_chat_logs
|
||||
from .const import DATA_COMPONENT
|
||||
from .default_agent import (
|
||||
METADATA_CUSTOM_FILE,
|
||||
METADATA_CUSTOM_SENTENCE,
|
||||
METADATA_FUZZY_MATCH,
|
||||
)
|
||||
from .entity import ConversationEntity
|
||||
from .models import ConversationInput
|
||||
|
||||
@@ -36,7 +45,6 @@ def async_setup(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, websocket_list_sentences)
|
||||
websocket_api.async_register_command(hass, websocket_hass_agent_debug)
|
||||
websocket_api.async_register_command(hass, websocket_hass_agent_language_scores)
|
||||
websocket_api.async_register_command(hass, websocket_subscribe_chat_logs)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
@@ -198,12 +206,150 @@ async def websocket_hass_agent_debug(
|
||||
language=msg.get("language", hass.config.language),
|
||||
agent_id=agent.entity_id,
|
||||
)
|
||||
result_dict = await agent.async_debug_recognize(user_input)
|
||||
result_dict: dict[str, Any] | None = None
|
||||
|
||||
if trigger_result := await agent.async_recognize_sentence_trigger(user_input):
|
||||
result_dict = {
|
||||
# Matched a user-defined sentence trigger.
|
||||
# We can't provide the response here without executing the
|
||||
# trigger.
|
||||
"match": True,
|
||||
"source": "trigger",
|
||||
"sentence_template": trigger_result.sentence_template or "",
|
||||
}
|
||||
elif intent_result := await agent.async_recognize_intent(user_input):
|
||||
successful_match = not intent_result.unmatched_entities
|
||||
result_dict = {
|
||||
# Name of the matching intent (or the closest)
|
||||
"intent": {
|
||||
"name": intent_result.intent.name,
|
||||
},
|
||||
# Slot values that would be received by the intent
|
||||
"slots": { # direct access to values
|
||||
entity_key: entity.text or entity.value
|
||||
for entity_key, entity in intent_result.entities.items()
|
||||
},
|
||||
# Extra slot details, such as the originally matched text
|
||||
"details": {
|
||||
entity_key: {
|
||||
"name": entity.name,
|
||||
"value": entity.value,
|
||||
"text": entity.text,
|
||||
}
|
||||
for entity_key, entity in intent_result.entities.items()
|
||||
},
|
||||
# Entities/areas/etc. that would be targeted
|
||||
"targets": {},
|
||||
# True if match was successful
|
||||
"match": successful_match,
|
||||
# Text of the sentence template that matched (or was closest)
|
||||
"sentence_template": "",
|
||||
# When match is incomplete, this will contain the best slot guesses
|
||||
"unmatched_slots": _get_unmatched_slots(intent_result),
|
||||
# True if match was not exact
|
||||
"fuzzy_match": False,
|
||||
}
|
||||
|
||||
if successful_match:
|
||||
result_dict["targets"] = {
|
||||
state.entity_id: {"matched": is_matched}
|
||||
for state, is_matched in _get_debug_targets(hass, intent_result)
|
||||
}
|
||||
|
||||
if intent_result.intent_sentence is not None:
|
||||
result_dict["sentence_template"] = intent_result.intent_sentence.text
|
||||
|
||||
if intent_result.intent_metadata:
|
||||
# Inspect metadata to determine if this matched a custom sentence
|
||||
if intent_result.intent_metadata.get(METADATA_CUSTOM_SENTENCE):
|
||||
result_dict["source"] = "custom"
|
||||
result_dict["file"] = intent_result.intent_metadata.get(
|
||||
METADATA_CUSTOM_FILE
|
||||
)
|
||||
else:
|
||||
result_dict["source"] = "builtin"
|
||||
|
||||
result_dict["fuzzy_match"] = intent_result.intent_metadata.get(
|
||||
METADATA_FUZZY_MATCH, False
|
||||
)
|
||||
|
||||
result_dicts.append(result_dict)
|
||||
|
||||
connection.send_result(msg["id"], {"results": result_dicts})
|
||||
|
||||
|
||||
def _get_debug_targets(
|
||||
hass: HomeAssistant,
|
||||
result: RecognizeResult,
|
||||
) -> Iterable[tuple[State, bool]]:
|
||||
"""Yield state/is_matched pairs for a hassil recognition."""
|
||||
entities = result.entities
|
||||
|
||||
name: str | None = None
|
||||
area_name: str | None = None
|
||||
domains: set[str] | None = None
|
||||
device_classes: set[str] | None = None
|
||||
state_names: set[str] | None = None
|
||||
|
||||
if "name" in entities:
|
||||
name = str(entities["name"].value)
|
||||
|
||||
if "area" in entities:
|
||||
area_name = str(entities["area"].value)
|
||||
|
||||
if "domain" in entities:
|
||||
domains = set(cv.ensure_list(entities["domain"].value))
|
||||
|
||||
if "device_class" in entities:
|
||||
device_classes = set(cv.ensure_list(entities["device_class"].value))
|
||||
|
||||
if "state" in entities:
|
||||
# HassGetState only
|
||||
state_names = set(cv.ensure_list(entities["state"].value))
|
||||
|
||||
if (
|
||||
(name is None)
|
||||
and (area_name is None)
|
||||
and (not domains)
|
||||
and (not device_classes)
|
||||
and (not state_names)
|
||||
):
|
||||
# Avoid "matching" all entities when there is no filter
|
||||
return
|
||||
|
||||
states = intent.async_match_states(
|
||||
hass,
|
||||
name=name,
|
||||
area_name=area_name,
|
||||
domains=domains,
|
||||
device_classes=device_classes,
|
||||
)
|
||||
|
||||
for state in states:
|
||||
# For queries, a target is "matched" based on its state
|
||||
is_matched = (state_names is None) or (state.state in state_names)
|
||||
yield state, is_matched
|
||||
|
||||
|
||||
def _get_unmatched_slots(
|
||||
result: RecognizeResult,
|
||||
) -> dict[str, str | int | float]:
|
||||
"""Return a dict of unmatched text/range slot entities."""
|
||||
unmatched_slots: dict[str, str | int | float] = {}
|
||||
for entity in result.unmatched_entities_list:
|
||||
if isinstance(entity, UnmatchedTextEntity):
|
||||
if entity.text == MISSING_ENTITY:
|
||||
# Don't report <missing> since these are just missing context
|
||||
# slots.
|
||||
continue
|
||||
|
||||
unmatched_slots[entity.name] = entity.text
|
||||
elif isinstance(entity, UnmatchedRangeEntity):
|
||||
unmatched_slots[entity.name] = entity.value
|
||||
|
||||
return unmatched_slots
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "conversation/agent/homeassistant/language_scores",
|
||||
@@ -218,13 +364,10 @@ async def websocket_hass_agent_language_scores(
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Get support scores per language."""
|
||||
agent = get_agent_manager(hass).default_agent
|
||||
assert agent is not None
|
||||
|
||||
language = msg.get("language", hass.config.language)
|
||||
country = msg.get("country", hass.config.country)
|
||||
|
||||
scores = await agent.async_get_language_scores()
|
||||
scores = await hass.async_add_executor_job(get_language_scores)
|
||||
matching_langs = language_util.matches(language, scores.keys(), country=country)
|
||||
preferred_lang = matching_langs[0] if matching_langs else language
|
||||
result = {
|
||||
@@ -267,28 +410,3 @@ class ConversationProcessView(http.HomeAssistantView):
|
||||
)
|
||||
|
||||
return self.json(result.as_dict())
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "conversation/chat_log/subscribe",
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
def websocket_subscribe_chat_logs(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Subscribe to all chat logs."""
|
||||
|
||||
@callback
|
||||
def forward_events(event_type: str, data: dict) -> None:
|
||||
"""Forward chat log events to websocket connection."""
|
||||
connection.send_message(
|
||||
{"type": "event", "event_type": event_type, "data": data}
|
||||
)
|
||||
|
||||
unsubscribe = async_subscribe_chat_logs(hass, forward_events)
|
||||
connection.subscriptions[msg["id"]] = unsubscribe
|
||||
connection.send_result(msg["id"])
|
||||
|
@@ -13,7 +13,7 @@ from propcache.api import cached_property
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
from homeassistant.const import ( # noqa: F401
|
||||
SERVICE_CLOSE_COVER,
|
||||
SERVICE_CLOSE_COVER_TILT,
|
||||
SERVICE_OPEN_COVER,
|
||||
@@ -24,9 +24,19 @@ from homeassistant.const import (
|
||||
SERVICE_STOP_COVER_TILT,
|
||||
SERVICE_TOGGLE,
|
||||
SERVICE_TOGGLE_COVER_TILT,
|
||||
STATE_CLOSED,
|
||||
STATE_CLOSING,
|
||||
STATE_OPEN,
|
||||
STATE_OPENING,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.deprecation import (
|
||||
DeprecatedConstantEnum,
|
||||
all_with_deprecated_constants,
|
||||
check_if_deprecated_constant,
|
||||
dir_with_deprecated_constants,
|
||||
)
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
@@ -53,6 +63,15 @@ class CoverState(StrEnum):
|
||||
OPENING = "opening"
|
||||
|
||||
|
||||
# STATE_* below are deprecated as of 2024.11
|
||||
# when imported from homeassistant.components.cover
|
||||
# use the CoverState enum instead.
|
||||
_DEPRECATED_STATE_CLOSED = DeprecatedConstantEnum(CoverState.CLOSED, "2025.11")
|
||||
_DEPRECATED_STATE_CLOSING = DeprecatedConstantEnum(CoverState.CLOSING, "2025.11")
|
||||
_DEPRECATED_STATE_OPEN = DeprecatedConstantEnum(CoverState.OPEN, "2025.11")
|
||||
_DEPRECATED_STATE_OPENING = DeprecatedConstantEnum(CoverState.OPENING, "2025.11")
|
||||
|
||||
|
||||
class CoverDeviceClass(StrEnum):
|
||||
"""Device class for cover."""
|
||||
|
||||
@@ -444,3 +463,11 @@ class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
return (
|
||||
fns["close"] if self._cover_is_last_toggle_direction_open else fns["open"]
|
||||
)
|
||||
|
||||
|
||||
# These can be removed if no deprecated constant are in this module anymore
|
||||
__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals())
|
||||
__dir__ = ft.partial(
|
||||
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
|
||||
)
|
||||
__all__ = all_with_deprecated_constants(globals())
|
||||
|
@@ -23,7 +23,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
|
||||
from homeassistant.util.ssl import client_context_no_verify
|
||||
|
||||
from .const import KEY_MAC, TIMEOUT_SEC
|
||||
from .const import KEY_MAC, TIMEOUT
|
||||
from .coordinator import DaikinConfigEntry, DaikinCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -42,7 +42,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaikinConfigEntry) -> bo
|
||||
session = async_get_clientsession(hass)
|
||||
host = conf[CONF_HOST]
|
||||
try:
|
||||
async with asyncio.timeout(TIMEOUT_SEC):
|
||||
async with asyncio.timeout(TIMEOUT):
|
||||
device: Appliance = await DaikinFactory(
|
||||
host,
|
||||
session,
|
||||
@@ -53,7 +53,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaikinConfigEntry) -> bo
|
||||
)
|
||||
_LOGGER.debug("Connection to %s successful", host)
|
||||
except TimeoutError as err:
|
||||
_LOGGER.debug("Connection to %s timed out in %s seconds", host, TIMEOUT_SEC)
|
||||
_LOGGER.debug("Connection to %s timed out in 60 seconds", host)
|
||||
raise ConfigEntryNotReady from err
|
||||
except ClientConnectionError as err:
|
||||
_LOGGER.debug("ClientConnectionError to %s", host)
|
||||
|
@@ -20,7 +20,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
from homeassistant.util.ssl import client_context_no_verify
|
||||
|
||||
from .const import DOMAIN, KEY_MAC, TIMEOUT_SEC
|
||||
from .const import DOMAIN, KEY_MAC, TIMEOUT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -84,7 +84,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
password = None
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(TIMEOUT_SEC):
|
||||
async with asyncio.timeout(TIMEOUT):
|
||||
device: Appliance = await DaikinFactory(
|
||||
host,
|
||||
async_get_clientsession(self.hass),
|
||||
|
@@ -24,4 +24,4 @@ ATTR_STATE_OFF = "off"
|
||||
KEY_MAC = "mac"
|
||||
KEY_IP = "ip"
|
||||
|
||||
TIMEOUT_SEC = 120
|
||||
TIMEOUT = 60
|
||||
|
@@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import DOMAIN, TIMEOUT_SEC
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -28,7 +28,7 @@ class DaikinCoordinator(DataUpdateCoordinator[None]):
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=device.values.get("name", DOMAIN),
|
||||
update_interval=timedelta(seconds=TIMEOUT_SEC),
|
||||
update_interval=timedelta(seconds=60),
|
||||
)
|
||||
self.device = device
|
||||
|
||||
|
@@ -32,7 +32,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
entry,
|
||||
options={**entry.options, CONF_SOURCE: source_entity_id},
|
||||
)
|
||||
hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||
|
||||
entry.async_on_unload(
|
||||
async_handle_source_entity_changes(
|
||||
@@ -47,9 +46,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
)
|
||||
)
|
||||
await hass.config_entries.async_forward_entry_setups(entry, (Platform.SENSOR,))
|
||||
entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
|
||||
return True
|
||||
|
||||
|
||||
async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Update listener, called when the config entry options are changed."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, (Platform.SENSOR,))
|
||||
|
@@ -140,7 +140,6 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
|
||||
|
||||
config_flow = CONFIG_FLOW
|
||||
options_flow = OPTIONS_FLOW
|
||||
options_flow_reloads = True
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 4
|
||||
|
@@ -6,13 +6,12 @@ from typing import TYPE_CHECKING, Any, Protocol
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_DOMAIN, CONF_OPTIONS
|
||||
from homeassistant.const import CONF_DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.condition import (
|
||||
Condition,
|
||||
ConditionCheckerType,
|
||||
ConditionConfig,
|
||||
trace_condition_function,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
@@ -56,40 +55,19 @@ class DeviceAutomationConditionProtocol(Protocol):
|
||||
class DeviceCondition(Condition):
|
||||
"""Device condition."""
|
||||
|
||||
_hass: HomeAssistant
|
||||
_config: ConfigType
|
||||
|
||||
@classmethod
|
||||
async def async_validate_complete_config(
|
||||
cls, hass: HomeAssistant, complete_config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate complete config."""
|
||||
complete_config = await async_validate_device_automation_config(
|
||||
hass,
|
||||
complete_config,
|
||||
cv.DEVICE_CONDITION_SCHEMA,
|
||||
DeviceAutomationType.CONDITION,
|
||||
)
|
||||
# Since we don't want to migrate device conditions to a new format
|
||||
# we just pass the entire config as options.
|
||||
complete_config[CONF_OPTIONS] = complete_config.copy()
|
||||
return complete_config
|
||||
def __init__(self, hass: HomeAssistant, config: ConfigType) -> None:
|
||||
"""Initialize condition."""
|
||||
self._config = config
|
||||
self._hass = hass
|
||||
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config.
|
||||
|
||||
This is here just to satisfy the abstract class interface. It is never called.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
|
||||
"""Initialize condition."""
|
||||
self._hass = hass
|
||||
assert config.options is not None
|
||||
self._config = config.options
|
||||
"""Validate device condition config."""
|
||||
return await async_validate_device_automation_config(
|
||||
hass, config, cv.DEVICE_CONDITION_SCHEMA, DeviceAutomationType.CONDITION
|
||||
)
|
||||
|
||||
async def async_get_checker(self) -> condition.ConditionCheckerType:
|
||||
"""Test a device condition."""
|
||||
|
@@ -126,7 +126,7 @@ class DevoloRemoteControl(DevoloDeviceEntity, BinarySensorEntity):
|
||||
self._attr_translation_key = "button"
|
||||
self._attr_translation_placeholders = {"key": str(key)}
|
||||
|
||||
def sync_callback(self, message: tuple) -> None:
|
||||
def _sync(self, message: tuple) -> None:
|
||||
"""Update the binary sensor state."""
|
||||
if (
|
||||
message[0] == self._remote_control_property.element_uid
|
||||
|
@@ -48,6 +48,7 @@ class DevoloDeviceEntity(Entity):
|
||||
)
|
||||
|
||||
self.subscriber: Subscriber | None = None
|
||||
self.sync_callback = self._sync
|
||||
|
||||
self._value: float
|
||||
|
||||
@@ -68,7 +69,7 @@ class DevoloDeviceEntity(Entity):
|
||||
self._device_instance.uid, self.subscriber
|
||||
)
|
||||
|
||||
def sync_callback(self, message: tuple) -> None:
|
||||
def _sync(self, message: tuple) -> None:
|
||||
"""Update the state."""
|
||||
if message[0] == self._attr_unique_id:
|
||||
self._value = message[1]
|
||||
|
@@ -185,7 +185,7 @@ class DevoloConsumptionEntity(DevoloMultiLevelDeviceEntity):
|
||||
"""
|
||||
return f"{self._attr_unique_id}_{self._sensor_type}"
|
||||
|
||||
def sync_callback(self, message: tuple) -> None:
|
||||
def _sync(self, message: tuple) -> None:
|
||||
"""Update the consumption sensor state."""
|
||||
if message[0] == self._attr_unique_id:
|
||||
self._value = getattr(
|
||||
|
@@ -13,3 +13,8 @@ class Subscriber:
|
||||
"""Initiate the subscriber."""
|
||||
self.name = name
|
||||
self.callback = callback
|
||||
|
||||
def update(self, message: str) -> None:
|
||||
"""Trigger hass to update the device."""
|
||||
_LOGGER.debug('%s got message "%s"', self.name, message)
|
||||
self.callback(message)
|
||||
|
@@ -64,7 +64,7 @@ class DevoloSwitch(DevoloDeviceEntity, SwitchEntity):
|
||||
"""Switch off the device."""
|
||||
self._binary_switch_property.set(state=False)
|
||||
|
||||
def sync_callback(self, message: tuple) -> None:
|
||||
def _sync(self, message: tuple) -> None:
|
||||
"""Update the binary switch state and consumption."""
|
||||
if message[0].startswith("devolo.BinarySwitch"):
|
||||
self._attr_is_on = self._device_instance.binary_switch_property[
|
||||
|
@@ -17,6 +17,6 @@
|
||||
"requirements": [
|
||||
"aiodhcpwatcher==1.2.1",
|
||||
"aiodiscover==2.7.1",
|
||||
"cached-ipaddress==1.0.1"
|
||||
"cached-ipaddress==0.10.0"
|
||||
]
|
||||
}
|
||||
|
@@ -56,16 +56,16 @@ async def async_setup_entry(
|
||||
hostname = entry.data[CONF_HOSTNAME]
|
||||
name = entry.data[CONF_NAME]
|
||||
|
||||
nameserver_ipv4 = entry.options[CONF_RESOLVER]
|
||||
nameserver_ipv6 = entry.options[CONF_RESOLVER_IPV6]
|
||||
resolver_ipv4 = entry.options[CONF_RESOLVER]
|
||||
resolver_ipv6 = entry.options[CONF_RESOLVER_IPV6]
|
||||
port_ipv4 = entry.options[CONF_PORT]
|
||||
port_ipv6 = entry.options[CONF_PORT_IPV6]
|
||||
|
||||
entities = []
|
||||
if entry.data[CONF_IPV4]:
|
||||
entities.append(WanIpSensor(name, hostname, nameserver_ipv4, False, port_ipv4))
|
||||
entities.append(WanIpSensor(name, hostname, resolver_ipv4, False, port_ipv4))
|
||||
if entry.data[CONF_IPV6]:
|
||||
entities.append(WanIpSensor(name, hostname, nameserver_ipv6, True, port_ipv6))
|
||||
entities.append(WanIpSensor(name, hostname, resolver_ipv6, True, port_ipv6))
|
||||
|
||||
async_add_entities(entities, update_before_add=True)
|
||||
|
||||
@@ -77,13 +77,11 @@ class WanIpSensor(SensorEntity):
|
||||
_attr_translation_key = "dnsip"
|
||||
_unrecorded_attributes = frozenset({"resolver", "querytype", "ip_addresses"})
|
||||
|
||||
resolver: aiodns.DNSResolver
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
hostname: str,
|
||||
nameserver: str,
|
||||
resolver: str,
|
||||
ipv6: bool,
|
||||
port: int,
|
||||
) -> None:
|
||||
@@ -92,11 +90,11 @@ class WanIpSensor(SensorEntity):
|
||||
self._attr_unique_id = f"{hostname}_{ipv6}"
|
||||
self.hostname = hostname
|
||||
self.port = port
|
||||
self.nameserver = nameserver
|
||||
self._resolver = resolver
|
||||
self.querytype: Literal["A", "AAAA"] = "AAAA" if ipv6 else "A"
|
||||
self._retries = DEFAULT_RETRIES
|
||||
self._attr_extra_state_attributes = {
|
||||
"resolver": nameserver,
|
||||
"resolver": resolver,
|
||||
"querytype": self.querytype,
|
||||
}
|
||||
self._attr_device_info = DeviceInfo(
|
||||
@@ -106,13 +104,13 @@ class WanIpSensor(SensorEntity):
|
||||
model=aiodns.__version__,
|
||||
name=name,
|
||||
)
|
||||
self.resolver: aiodns.DNSResolver
|
||||
self.create_dns_resolver()
|
||||
|
||||
def create_dns_resolver(self) -> None:
|
||||
"""Create the DNS resolver."""
|
||||
self.resolver = aiodns.DNSResolver(
|
||||
nameservers=[self.nameserver], tcp_port=self.port, udp_port=self.port
|
||||
)
|
||||
self.resolver = aiodns.DNSResolver(tcp_port=self.port, udp_port=self.port)
|
||||
self.resolver.nameservers = [self._resolver]
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Get the current DNS IP address for hostname."""
|
||||
|
@@ -24,7 +24,6 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.unit_conversion import EnergyConverter
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
@@ -147,7 +146,6 @@ class DukeEnergyCoordinator(DataUpdateCoordinator[None]):
|
||||
name=f"{name_prefix} Consumption",
|
||||
source=DOMAIN,
|
||||
statistic_id=consumption_statistic_id,
|
||||
unit_class=EnergyConverter.UNIT_CLASS,
|
||||
unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR
|
||||
if meter["serviceType"] == "ELECTRIC"
|
||||
else UnitOfVolume.CENTUM_CUBIC_FEET,
|
||||
|
@@ -116,9 +116,6 @@
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"active_map": {
|
||||
"default": "mdi:floor-plan"
|
||||
},
|
||||
"water_amount": {
|
||||
"default": "mdi:water"
|
||||
},
|
||||
|
@@ -2,13 +2,12 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import Any
|
||||
|
||||
from deebot_client.capabilities import CapabilityMap, CapabilitySet, CapabilitySetTypes
|
||||
from deebot_client.capabilities import CapabilitySetTypes
|
||||
from deebot_client.device import Device
|
||||
from deebot_client.events import WorkModeEvent
|
||||
from deebot_client.events.base import Event
|
||||
from deebot_client.events.map import CachedMapInfoEvent, MajorMapEvent
|
||||
from deebot_client.events.water_info import WaterAmountEvent
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
@@ -17,11 +16,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import EcovacsConfigEntry
|
||||
from .entity import (
|
||||
EcovacsCapabilityEntityDescription,
|
||||
EcovacsDescriptionEntity,
|
||||
EcovacsEntity,
|
||||
)
|
||||
from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity
|
||||
from .util import get_name_key, get_supported_entities
|
||||
|
||||
|
||||
@@ -71,12 +66,6 @@ async def async_setup_entry(
|
||||
entities = get_supported_entities(
|
||||
controller, EcovacsSelectEntity, ENTITY_DESCRIPTIONS
|
||||
)
|
||||
entities.extend(
|
||||
EcovacsActiveMapSelectEntity(device, device.capabilities.map)
|
||||
for device in controller.devices
|
||||
if (map_cap := device.capabilities.map)
|
||||
and isinstance(map_cap.major, CapabilitySet)
|
||||
)
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -114,76 +103,3 @@ class EcovacsSelectEntity[EventT: Event](
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Change the selected option."""
|
||||
await self._device.execute_command(self._capability.set(option))
|
||||
|
||||
|
||||
class EcovacsActiveMapSelectEntity(
|
||||
EcovacsEntity[CapabilityMap],
|
||||
SelectEntity,
|
||||
):
|
||||
"""Ecovacs active map select entity."""
|
||||
|
||||
entity_description = SelectEntityDescription(
|
||||
key="active_map",
|
||||
translation_key="active_map",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: Device,
|
||||
capability: CapabilityMap,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize entity."""
|
||||
super().__init__(device, capability, **kwargs)
|
||||
self._option_to_id: dict[str, str] = {}
|
||||
self._id_to_option: dict[str, str] = {}
|
||||
|
||||
self._handle_on_cached_map(
|
||||
device.events.get_last_event(CachedMapInfoEvent)
|
||||
or CachedMapInfoEvent(set())
|
||||
)
|
||||
|
||||
def _handle_on_cached_map(self, event: CachedMapInfoEvent) -> None:
|
||||
self._id_to_option.clear()
|
||||
self._option_to_id.clear()
|
||||
|
||||
for map_info in event.maps:
|
||||
name = map_info.name if map_info.name else map_info.id
|
||||
self._id_to_option[map_info.id] = name
|
||||
self._option_to_id[name] = map_info.id
|
||||
|
||||
if map_info.using:
|
||||
self._attr_current_option = name
|
||||
|
||||
if self._attr_current_option not in self._option_to_id:
|
||||
self._attr_current_option = None
|
||||
|
||||
# Sort named maps first, then numeric IDs (unnamed maps during building) in ascending order.
|
||||
self._attr_options = sorted(
|
||||
self._option_to_id.keys(), key=lambda x: (x.isdigit(), x.lower())
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Set up the event listeners now that hass is ready."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
async def on_cached_map(event: CachedMapInfoEvent) -> None:
|
||||
self._handle_on_cached_map(event)
|
||||
self.async_write_ha_state()
|
||||
|
||||
self._subscribe(self._capability.cached_info.event, on_cached_map)
|
||||
|
||||
async def on_major_map(event: MajorMapEvent) -> None:
|
||||
self._attr_current_option = self._id_to_option.get(event.map_id)
|
||||
self.async_write_ha_state()
|
||||
|
||||
self._subscribe(self._capability.major.event, on_major_map)
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Change the selected option."""
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(self._capability.major, CapabilitySet)
|
||||
await self._device.execute_command(
|
||||
self._capability.major.set(self._option_to_id[option])
|
||||
)
|
||||
|
@@ -2,4 +2,3 @@ raw_get_positions:
|
||||
target:
|
||||
entity:
|
||||
domain: vacuum
|
||||
integration: ecovacs
|
||||
|
@@ -178,9 +178,6 @@
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"active_map": {
|
||||
"name": "Active map"
|
||||
},
|
||||
"water_amount": {
|
||||
"name": "[%key:component::ecovacs::entity::number::water_amount::name%]",
|
||||
"state": {
|
||||
|
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
from elevenlabs import AsyncElevenLabs, Model
|
||||
from elevenlabs.core import ApiError
|
||||
@@ -19,14 +18,9 @@ from homeassistant.exceptions import (
|
||||
)
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
|
||||
from .const import CONF_MODEL, CONF_STT_MODEL
|
||||
from .const import CONF_MODEL
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.STT,
|
||||
Platform.TTS,
|
||||
]
|
||||
PLATFORMS: list[Platform] = [Platform.TTS]
|
||||
|
||||
|
||||
async def get_model_by_id(client: AsyncElevenLabs, model_id: str) -> Model | None:
|
||||
@@ -45,7 +39,6 @@ class ElevenLabsData:
|
||||
|
||||
client: AsyncElevenLabs
|
||||
model: Model
|
||||
stt_model: str
|
||||
|
||||
|
||||
type ElevenLabsConfigEntry = ConfigEntry[ElevenLabsData]
|
||||
@@ -69,9 +62,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ElevenLabsConfigEntry) -
|
||||
if model is None or (not model.languages):
|
||||
raise ConfigEntryError("Model could not be resolved")
|
||||
|
||||
entry.runtime_data = ElevenLabsData(
|
||||
client=client, model=model, stt_model=entry.options[CONF_STT_MODEL]
|
||||
)
|
||||
entry.runtime_data = ElevenLabsData(client=client, model=model)
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
@@ -87,44 +78,3 @@ async def update_listener(
|
||||
) -> None:
|
||||
"""Handle options update."""
|
||||
await hass.config_entries.async_reload(config_entry.entry_id)
|
||||
|
||||
|
||||
async def async_migrate_entry(
|
||||
hass: HomeAssistant, config_entry: ElevenLabsConfigEntry
|
||||
) -> bool:
|
||||
"""Migrate old config entry to new format."""
|
||||
|
||||
_LOGGER.debug(
|
||||
"Migrating configuration from version %s.%s",
|
||||
config_entry.version,
|
||||
config_entry.minor_version,
|
||||
)
|
||||
|
||||
if config_entry.version > 1:
|
||||
# This means the user has downgraded from a future version
|
||||
return False
|
||||
|
||||
if config_entry.version == 1:
|
||||
new_options = {**config_entry.options}
|
||||
|
||||
if config_entry.minor_version < 2:
|
||||
# Add defaults only if they’re not already present
|
||||
if "stt_auto_language" not in new_options:
|
||||
new_options["stt_auto_language"] = False
|
||||
if "stt_model" not in new_options:
|
||||
new_options["stt_model"] = "scribe_v1"
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry,
|
||||
options=new_options,
|
||||
minor_version=2,
|
||||
version=1,
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Migration to configuration version %s.%s successful",
|
||||
config_entry.version,
|
||||
config_entry.minor_version,
|
||||
)
|
||||
|
||||
return True # already up to date
|
||||
|
@@ -25,20 +25,15 @@ from .const import (
|
||||
CONF_MODEL,
|
||||
CONF_SIMILARITY,
|
||||
CONF_STABILITY,
|
||||
CONF_STT_AUTO_LANGUAGE,
|
||||
CONF_STT_MODEL,
|
||||
CONF_STYLE,
|
||||
CONF_USE_SPEAKER_BOOST,
|
||||
CONF_VOICE,
|
||||
DEFAULT_MODEL,
|
||||
DEFAULT_SIMILARITY,
|
||||
DEFAULT_STABILITY,
|
||||
DEFAULT_STT_AUTO_LANGUAGE,
|
||||
DEFAULT_STT_MODEL,
|
||||
DEFAULT_STYLE,
|
||||
DEFAULT_TTS_MODEL,
|
||||
DEFAULT_USE_SPEAKER_BOOST,
|
||||
DOMAIN,
|
||||
STT_MODELS,
|
||||
)
|
||||
|
||||
USER_STEP_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str})
|
||||
@@ -73,7 +68,6 @@ class ElevenLabsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for ElevenLabs text-to-speech."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 2
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -94,12 +88,7 @@ class ElevenLabsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_create_entry(
|
||||
title="ElevenLabs",
|
||||
data=user_input,
|
||||
options={
|
||||
CONF_MODEL: DEFAULT_TTS_MODEL,
|
||||
CONF_VOICE: list(voices)[0],
|
||||
CONF_STT_MODEL: DEFAULT_STT_MODEL,
|
||||
CONF_STT_AUTO_LANGUAGE: False,
|
||||
},
|
||||
options={CONF_MODEL: DEFAULT_MODEL, CONF_VOICE: list(voices)[0]},
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=USER_STEP_SCHEMA, errors=errors
|
||||
@@ -124,9 +113,6 @@ class ElevenLabsOptionsFlow(OptionsFlow):
|
||||
self.models: dict[str, str] = {}
|
||||
self.model: str | None = None
|
||||
self.voice: str | None = None
|
||||
self.stt_models: dict[str, str] = STT_MODELS
|
||||
self.stt_model: str | None = None
|
||||
self.auto_language: bool | None = None
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -140,8 +126,6 @@ class ElevenLabsOptionsFlow(OptionsFlow):
|
||||
if user_input is not None:
|
||||
self.model = user_input[CONF_MODEL]
|
||||
self.voice = user_input[CONF_VOICE]
|
||||
self.stt_model = user_input[CONF_STT_MODEL]
|
||||
self.auto_language = user_input[CONF_STT_AUTO_LANGUAGE]
|
||||
configure_voice = user_input.pop(CONF_CONFIGURE_VOICE)
|
||||
if configure_voice:
|
||||
return await self.async_step_voice_settings()
|
||||
@@ -181,22 +165,6 @@ class ElevenLabsOptionsFlow(OptionsFlow):
|
||||
]
|
||||
)
|
||||
),
|
||||
vol.Required(
|
||||
CONF_STT_MODEL,
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
SelectOptionDict(label=model_name, value=model_id)
|
||||
for model_id, model_name in self.stt_models.items()
|
||||
]
|
||||
)
|
||||
),
|
||||
vol.Required(
|
||||
CONF_STT_AUTO_LANGUAGE,
|
||||
default=self.config_entry.options.get(
|
||||
CONF_STT_AUTO_LANGUAGE, DEFAULT_STT_AUTO_LANGUAGE
|
||||
),
|
||||
): bool,
|
||||
vol.Required(CONF_CONFIGURE_VOICE, default=False): bool,
|
||||
}
|
||||
),
|
||||
@@ -211,8 +179,6 @@ class ElevenLabsOptionsFlow(OptionsFlow):
|
||||
if user_input is not None:
|
||||
user_input[CONF_MODEL] = self.model
|
||||
user_input[CONF_VOICE] = self.voice
|
||||
user_input[CONF_STT_MODEL] = self.stt_model
|
||||
user_input[CONF_STT_AUTO_LANGUAGE] = self.auto_language
|
||||
return self.async_create_entry(
|
||||
title="ElevenLabs",
|
||||
data=user_input,
|
||||
|
@@ -7,123 +7,12 @@ CONF_MODEL = "model"
|
||||
CONF_CONFIGURE_VOICE = "configure_voice"
|
||||
CONF_STABILITY = "stability"
|
||||
CONF_SIMILARITY = "similarity"
|
||||
CONF_STT_AUTO_LANGUAGE = "stt_auto_language"
|
||||
CONF_STT_MODEL = "stt_model"
|
||||
CONF_STYLE = "style"
|
||||
CONF_USE_SPEAKER_BOOST = "use_speaker_boost"
|
||||
DOMAIN = "elevenlabs"
|
||||
|
||||
DEFAULT_TTS_MODEL = "eleven_multilingual_v2"
|
||||
DEFAULT_MODEL = "eleven_multilingual_v2"
|
||||
DEFAULT_STABILITY = 0.5
|
||||
DEFAULT_SIMILARITY = 0.75
|
||||
DEFAULT_STT_AUTO_LANGUAGE = False
|
||||
DEFAULT_STT_MODEL = "scribe_v1"
|
||||
DEFAULT_STYLE = 0
|
||||
DEFAULT_USE_SPEAKER_BOOST = True
|
||||
|
||||
STT_LANGUAGES = [
|
||||
"af-ZA", # Afrikaans
|
||||
"am-ET", # Amharic
|
||||
"ar-SA", # Arabic
|
||||
"hy-AM", # Armenian
|
||||
"as-IN", # Assamese
|
||||
"ast-ES", # Asturian
|
||||
"az-AZ", # Azerbaijani
|
||||
"be-BY", # Belarusian
|
||||
"bn-IN", # Bengali
|
||||
"bs-BA", # Bosnian
|
||||
"bg-BG", # Bulgarian
|
||||
"my-MM", # Burmese
|
||||
"yue-HK", # Cantonese
|
||||
"ca-ES", # Catalan
|
||||
"ceb-PH", # Cebuano
|
||||
"ny-MW", # Chichewa
|
||||
"hr-HR", # Croatian
|
||||
"cs-CZ", # Czech
|
||||
"da-DK", # Danish
|
||||
"nl-NL", # Dutch
|
||||
"en-US", # English
|
||||
"et-EE", # Estonian
|
||||
"fil-PH", # Filipino
|
||||
"fi-FI", # Finnish
|
||||
"fr-FR", # French
|
||||
"ff-SN", # Fulah
|
||||
"gl-ES", # Galician
|
||||
"lg-UG", # Ganda
|
||||
"ka-GE", # Georgian
|
||||
"de-DE", # German
|
||||
"el-GR", # Greek
|
||||
"gu-IN", # Gujarati
|
||||
"ha-NG", # Hausa
|
||||
"he-IL", # Hebrew
|
||||
"hi-IN", # Hindi
|
||||
"hu-HU", # Hungarian
|
||||
"is-IS", # Icelandic
|
||||
"ig-NG", # Igbo
|
||||
"id-ID", # Indonesian
|
||||
"ga-IE", # Irish
|
||||
"it-IT", # Italian
|
||||
"ja-JP", # Japanese
|
||||
"jv-ID", # Javanese
|
||||
"kea-CV", # Kabuverdianu
|
||||
"kn-IN", # Kannada
|
||||
"kk-KZ", # Kazakh
|
||||
"km-KH", # Khmer
|
||||
"ko-KR", # Korean
|
||||
"ku-TR", # Kurdish
|
||||
"ky-KG", # Kyrgyz
|
||||
"lo-LA", # Lao
|
||||
"lv-LV", # Latvian
|
||||
"ln-CD", # Lingala
|
||||
"lt-LT", # Lithuanian
|
||||
"luo-KE", # Luo
|
||||
"lb-LU", # Luxembourgish
|
||||
"mk-MK", # Macedonian
|
||||
"ms-MY", # Malay
|
||||
"ml-IN", # Malayalam
|
||||
"mt-MT", # Maltese
|
||||
"zh-CN", # Mandarin Chinese
|
||||
"mi-NZ", # Māori
|
||||
"mr-IN", # Marathi
|
||||
"mn-MN", # Mongolian
|
||||
"ne-NP", # Nepali
|
||||
"nso-ZA", # Northern Sotho
|
||||
"no-NO", # Norwegian
|
||||
"oc-FR", # Occitan
|
||||
"or-IN", # Odia
|
||||
"ps-AF", # Pashto
|
||||
"fa-IR", # Persian
|
||||
"pl-PL", # Polish
|
||||
"pt-PT", # Portuguese
|
||||
"pa-IN", # Punjabi
|
||||
"ro-RO", # Romanian
|
||||
"ru-RU", # Russian
|
||||
"sr-RS", # Serbian
|
||||
"sn-ZW", # Shona
|
||||
"sd-PK", # Sindhi
|
||||
"sk-SK", # Slovak
|
||||
"sl-SI", # Slovenian
|
||||
"so-SO", # Somali
|
||||
"es-ES", # Spanish
|
||||
"sw-KE", # Swahili
|
||||
"sv-SE", # Swedish
|
||||
"ta-IN", # Tamil
|
||||
"tg-TJ", # Tajik
|
||||
"te-IN", # Telugu
|
||||
"th-TH", # Thai
|
||||
"tr-TR", # Turkish
|
||||
"uk-UA", # Ukrainian
|
||||
"umb-AO", # Umbundu
|
||||
"ur-PK", # Urdu
|
||||
"uz-UZ", # Uzbek
|
||||
"vi-VN", # Vietnamese
|
||||
"cy-GB", # Welsh
|
||||
"wo-SN", # Wolof
|
||||
"xh-ZA", # Xhosa
|
||||
"zu-ZA", # Zulu
|
||||
]
|
||||
|
||||
STT_MODELS = {
|
||||
"scribe_v1": "Scribe v1",
|
||||
"scribe_v1_experimental": "Scribe v1 Experimental",
|
||||
}
|
||||
|
@@ -21,15 +21,11 @@
|
||||
"data": {
|
||||
"voice": "Voice",
|
||||
"model": "Model",
|
||||
"stt_model": "Speech-to-Text Model",
|
||||
"stt_auto_language": "Auto-detect language",
|
||||
"configure_voice": "Configure advanced voice settings"
|
||||
},
|
||||
"data_description": {
|
||||
"voice": "Voice to use for text-to-speech.",
|
||||
"voice": "Voice to use for the TTS.",
|
||||
"model": "ElevenLabs model to use. Please note that not all models support all languages equally well.",
|
||||
"stt_model": "Speech-to-Text model to use.",
|
||||
"stt_auto_language": "Automatically detect the spoken language for speech-to-text.",
|
||||
"configure_voice": "Configure advanced voice settings. Find more information in the ElevenLabs documentation."
|
||||
}
|
||||
},
|
||||
@@ -48,17 +44,5 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"tts": {
|
||||
"elevenlabs_tts": {
|
||||
"name": "Text-to-Speech"
|
||||
}
|
||||
},
|
||||
"stt": {
|
||||
"elevenlabs_stt": {
|
||||
"name": "Speech-to-Text"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,207 +0,0 @@
|
||||
"""Support for the ElevenLabs speech-to-text service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterable
|
||||
from io import BytesIO
|
||||
import logging
|
||||
|
||||
from elevenlabs import AsyncElevenLabs
|
||||
from elevenlabs.core import ApiError
|
||||
from elevenlabs.types import Model
|
||||
|
||||
from homeassistant.components import stt
|
||||
from homeassistant.components.stt import (
|
||||
AudioBitRates,
|
||||
AudioChannels,
|
||||
AudioCodecs,
|
||||
AudioFormats,
|
||||
AudioSampleRates,
|
||||
SpeechMetadata,
|
||||
SpeechResultState,
|
||||
SpeechToTextEntity,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import ElevenLabsConfigEntry
|
||||
from .const import (
|
||||
CONF_STT_AUTO_LANGUAGE,
|
||||
DEFAULT_STT_AUTO_LANGUAGE,
|
||||
DOMAIN,
|
||||
STT_LANGUAGES,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
PARALLEL_UPDATES = 10
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ElevenLabsConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up ElevenLabs stt platform via config entry."""
|
||||
client = config_entry.runtime_data.client
|
||||
auto_detect = config_entry.options.get(
|
||||
CONF_STT_AUTO_LANGUAGE, DEFAULT_STT_AUTO_LANGUAGE
|
||||
)
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
ElevenLabsSTTEntity(
|
||||
client,
|
||||
config_entry.runtime_data.model,
|
||||
config_entry.runtime_data.stt_model,
|
||||
config_entry.entry_id,
|
||||
auto_detect_language=auto_detect,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class ElevenLabsSTTEntity(SpeechToTextEntity):
|
||||
"""The ElevenLabs STT API entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "elevenlabs_stt"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: AsyncElevenLabs,
|
||||
model: Model,
|
||||
stt_model: str,
|
||||
entry_id: str,
|
||||
auto_detect_language: bool = False,
|
||||
) -> None:
|
||||
"""Init ElevenLabs TTS service."""
|
||||
self._client = client
|
||||
self._auto_detect_language = auto_detect_language
|
||||
self._stt_model = stt_model
|
||||
|
||||
# Entity attributes
|
||||
self._attr_unique_id = entry_id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, entry_id)},
|
||||
manufacturer="ElevenLabs",
|
||||
model=model.name,
|
||||
name="ElevenLabs",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
@property
|
||||
def supported_languages(self) -> list[str]:
|
||||
"""Return a list of supported languages."""
|
||||
return STT_LANGUAGES
|
||||
|
||||
@property
|
||||
def supported_formats(self) -> list[AudioFormats]:
|
||||
"""Return a list of supported formats."""
|
||||
return [AudioFormats.WAV, AudioFormats.OGG]
|
||||
|
||||
@property
|
||||
def supported_codecs(self) -> list[AudioCodecs]:
|
||||
"""Return a list of supported codecs."""
|
||||
return [AudioCodecs.PCM, AudioCodecs.OPUS]
|
||||
|
||||
@property
|
||||
def supported_bit_rates(self) -> list[AudioBitRates]:
|
||||
"""Return a list of supported bit rates."""
|
||||
return [AudioBitRates.BITRATE_16]
|
||||
|
||||
@property
|
||||
def supported_sample_rates(self) -> list[AudioSampleRates]:
|
||||
"""Return a list of supported sample rates."""
|
||||
return [AudioSampleRates.SAMPLERATE_16000]
|
||||
|
||||
@property
|
||||
def supported_channels(self) -> list[AudioChannels]:
|
||||
"""Return a list of supported channels."""
|
||||
return [
|
||||
AudioChannels.CHANNEL_MONO,
|
||||
AudioChannels.CHANNEL_STEREO,
|
||||
]
|
||||
|
||||
async def async_process_audio_stream(
|
||||
self, metadata: SpeechMetadata, stream: AsyncIterable[bytes]
|
||||
) -> stt.SpeechResult:
|
||||
"""Process an audio stream to STT service."""
|
||||
_LOGGER.debug(
|
||||
"Processing audio stream for STT: model=%s, language=%s, format=%s, codec=%s, sample_rate=%s, channels=%s, bit_rate=%s",
|
||||
self._stt_model,
|
||||
metadata.language,
|
||||
metadata.format,
|
||||
metadata.codec,
|
||||
metadata.sample_rate,
|
||||
metadata.channel,
|
||||
metadata.bit_rate,
|
||||
)
|
||||
|
||||
if self._auto_detect_language:
|
||||
lang_code = None
|
||||
else:
|
||||
language = metadata.language
|
||||
if language.lower() not in [lang.lower() for lang in STT_LANGUAGES]:
|
||||
_LOGGER.warning("Unsupported language: %s", language)
|
||||
return stt.SpeechResult(None, SpeechResultState.ERROR)
|
||||
lang_code = language.split("-")[0]
|
||||
|
||||
raw_pcm_compatible = (
|
||||
metadata.codec == AudioCodecs.PCM
|
||||
and metadata.sample_rate == AudioSampleRates.SAMPLERATE_16000
|
||||
and metadata.channel == AudioChannels.CHANNEL_MONO
|
||||
and metadata.bit_rate == AudioBitRates.BITRATE_16
|
||||
)
|
||||
if raw_pcm_compatible:
|
||||
file_format = "pcm_s16le_16"
|
||||
elif metadata.codec == AudioCodecs.PCM:
|
||||
_LOGGER.warning("PCM input does not meet expected raw format requirements")
|
||||
return stt.SpeechResult(None, SpeechResultState.ERROR)
|
||||
else:
|
||||
file_format = "other"
|
||||
|
||||
audio = b""
|
||||
async for chunk in stream:
|
||||
audio += chunk
|
||||
|
||||
_LOGGER.debug("Finished reading audio stream, total size: %d bytes", len(audio))
|
||||
if not audio:
|
||||
_LOGGER.warning("No audio received in stream")
|
||||
return stt.SpeechResult(None, SpeechResultState.ERROR)
|
||||
|
||||
lang_display = lang_code if lang_code else "auto-detected"
|
||||
|
||||
_LOGGER.debug(
|
||||
"Transcribing audio (%s), format: %s, size: %d bytes",
|
||||
lang_display,
|
||||
file_format,
|
||||
len(audio),
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self._client.speech_to_text.convert(
|
||||
file=BytesIO(audio),
|
||||
file_format=file_format,
|
||||
model_id=self._stt_model,
|
||||
language_code=lang_code,
|
||||
tag_audio_events=False,
|
||||
num_speakers=1,
|
||||
diarize=False,
|
||||
)
|
||||
except ApiError as exc:
|
||||
_LOGGER.error("Error during processing of STT request: %s", exc)
|
||||
return stt.SpeechResult(None, SpeechResultState.ERROR)
|
||||
|
||||
text = response.text or ""
|
||||
detected_lang_code = response.language_code or "?"
|
||||
detected_lang_prob = response.language_probability or "?"
|
||||
|
||||
_LOGGER.debug(
|
||||
"Transcribed text is in language %s (probability %s): %s",
|
||||
detected_lang_code,
|
||||
detected_lang_prob,
|
||||
text,
|
||||
)
|
||||
|
||||
return stt.SpeechResult(text, SpeechResultState.SUCCESS)
|
@@ -71,6 +71,7 @@ async def async_setup_entry(
|
||||
voices,
|
||||
default_voice_id,
|
||||
config_entry.entry_id,
|
||||
config_entry.title,
|
||||
voice_settings,
|
||||
)
|
||||
]
|
||||
@@ -82,8 +83,6 @@ class ElevenLabsTTSEntity(TextToSpeechEntity):
|
||||
|
||||
_attr_supported_options = [ATTR_VOICE, ATTR_MODEL]
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "elevenlabs_tts"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -92,6 +91,7 @@ class ElevenLabsTTSEntity(TextToSpeechEntity):
|
||||
voices: list[ElevenLabsVoice],
|
||||
default_voice_id: str,
|
||||
entry_id: str,
|
||||
title: str,
|
||||
voice_settings: VoiceSettings,
|
||||
) -> None:
|
||||
"""Init ElevenLabs TTS service."""
|
||||
@@ -112,11 +112,11 @@ class ElevenLabsTTSEntity(TextToSpeechEntity):
|
||||
|
||||
# Entity attributes
|
||||
self._attr_unique_id = entry_id
|
||||
self._attr_name = title
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, entry_id)},
|
||||
manufacturer="ElevenLabs",
|
||||
model=model.name,
|
||||
name="ElevenLabs",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
)
|
||||
self._attr_supported_languages = [
|
||||
|
@@ -20,7 +20,6 @@ from homeassistant.components.recorder.statistics import (
|
||||
from homeassistant.components.recorder.util import get_instance
|
||||
from homeassistant.const import UnitOfEnergy
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.unit_conversion import EnergyConverter
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
@@ -154,7 +153,6 @@ class ElviaImporter:
|
||||
name=f"{self.metering_point_id} Consumption",
|
||||
source=DOMAIN,
|
||||
statistic_id=statistic_id,
|
||||
unit_class=EnergyConverter.UNIT_CLASS,
|
||||
unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
),
|
||||
statistics=statistics,
|
||||
|
@@ -7,7 +7,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyenphase"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pyenphase==2.4.0"],
|
||||
"requirements": ["pyenphase==2.3.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_enphase-envoy._tcp.local."
|
||||
|
@@ -396,7 +396,6 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription):
|
||||
int | float | str | CtType | CtMeterStatus | CtStatusFlags | CtState | None,
|
||||
]
|
||||
on_phase: str | None
|
||||
cttype: str | None = None
|
||||
|
||||
|
||||
CT_NET_CONSUMPTION_SENSORS = (
|
||||
@@ -410,7 +409,6 @@ CT_NET_CONSUMPTION_SENSORS = (
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("energy_delivered"),
|
||||
on_phase=None,
|
||||
cttype=CtType.NET_CONSUMPTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="lifetime_net_production",
|
||||
@@ -422,7 +420,6 @@ CT_NET_CONSUMPTION_SENSORS = (
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("energy_received"),
|
||||
on_phase=None,
|
||||
cttype=CtType.NET_CONSUMPTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="net_consumption",
|
||||
@@ -434,7 +431,6 @@ CT_NET_CONSUMPTION_SENSORS = (
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("active_power"),
|
||||
on_phase=None,
|
||||
cttype=CtType.NET_CONSUMPTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="frequency",
|
||||
@@ -446,7 +442,6 @@ CT_NET_CONSUMPTION_SENSORS = (
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("frequency"),
|
||||
on_phase=None,
|
||||
cttype=CtType.NET_CONSUMPTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="voltage",
|
||||
@@ -459,7 +454,6 @@ CT_NET_CONSUMPTION_SENSORS = (
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("voltage"),
|
||||
on_phase=None,
|
||||
cttype=CtType.NET_CONSUMPTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="net_ct_current",
|
||||
@@ -472,7 +466,6 @@ CT_NET_CONSUMPTION_SENSORS = (
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("current"),
|
||||
on_phase=None,
|
||||
cttype=CtType.NET_CONSUMPTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="net_ct_powerfactor",
|
||||
@@ -483,7 +476,6 @@ CT_NET_CONSUMPTION_SENSORS = (
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("power_factor"),
|
||||
on_phase=None,
|
||||
cttype=CtType.NET_CONSUMPTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="net_consumption_ct_metering_status",
|
||||
@@ -494,7 +486,6 @@ CT_NET_CONSUMPTION_SENSORS = (
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("metering_status"),
|
||||
on_phase=None,
|
||||
cttype=CtType.NET_CONSUMPTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="net_consumption_ct_status_flags",
|
||||
@@ -504,7 +495,6 @@ CT_NET_CONSUMPTION_SENSORS = (
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
|
||||
on_phase=None,
|
||||
cttype=CtType.NET_CONSUMPTION,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -535,7 +525,6 @@ CT_PRODUCTION_SENSORS = (
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("frequency"),
|
||||
on_phase=None,
|
||||
cttype=CtType.PRODUCTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="production_ct_voltage",
|
||||
@@ -548,7 +537,6 @@ CT_PRODUCTION_SENSORS = (
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("voltage"),
|
||||
on_phase=None,
|
||||
cttype=CtType.PRODUCTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="production_ct_current",
|
||||
@@ -561,7 +549,6 @@ CT_PRODUCTION_SENSORS = (
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("current"),
|
||||
on_phase=None,
|
||||
cttype=CtType.PRODUCTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="production_ct_powerfactor",
|
||||
@@ -572,7 +559,6 @@ CT_PRODUCTION_SENSORS = (
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("power_factor"),
|
||||
on_phase=None,
|
||||
cttype=CtType.PRODUCTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="production_ct_metering_status",
|
||||
@@ -583,7 +569,6 @@ CT_PRODUCTION_SENSORS = (
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("metering_status"),
|
||||
on_phase=None,
|
||||
cttype=CtType.PRODUCTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="production_ct_status_flags",
|
||||
@@ -593,7 +578,6 @@ CT_PRODUCTION_SENSORS = (
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
|
||||
on_phase=None,
|
||||
cttype=CtType.PRODUCTION,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -623,7 +607,6 @@ CT_STORAGE_SENSORS = (
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("energy_delivered"),
|
||||
on_phase=None,
|
||||
cttype=CtType.STORAGE,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="lifetime_battery_charged",
|
||||
@@ -635,7 +618,6 @@ CT_STORAGE_SENSORS = (
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("energy_received"),
|
||||
on_phase=None,
|
||||
cttype=CtType.STORAGE,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="battery_discharge",
|
||||
@@ -647,7 +629,6 @@ CT_STORAGE_SENSORS = (
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("active_power"),
|
||||
on_phase=None,
|
||||
cttype=CtType.STORAGE,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="storage_ct_frequency",
|
||||
@@ -659,7 +640,6 @@ CT_STORAGE_SENSORS = (
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("frequency"),
|
||||
on_phase=None,
|
||||
cttype=CtType.STORAGE,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="storage_voltage",
|
||||
@@ -672,7 +652,6 @@ CT_STORAGE_SENSORS = (
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("voltage"),
|
||||
on_phase=None,
|
||||
cttype=CtType.STORAGE,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="storage_ct_current",
|
||||
@@ -685,7 +664,6 @@ CT_STORAGE_SENSORS = (
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("current"),
|
||||
on_phase=None,
|
||||
cttype=CtType.STORAGE,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="storage_ct_powerfactor",
|
||||
@@ -696,7 +674,6 @@ CT_STORAGE_SENSORS = (
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("power_factor"),
|
||||
on_phase=None,
|
||||
cttype=CtType.STORAGE,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="storage_ct_metering_status",
|
||||
@@ -707,7 +684,6 @@ CT_STORAGE_SENSORS = (
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("metering_status"),
|
||||
on_phase=None,
|
||||
cttype=CtType.STORAGE,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="storage_ct_status_flags",
|
||||
@@ -717,7 +693,6 @@ CT_STORAGE_SENSORS = (
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
|
||||
on_phase=None,
|
||||
cttype=CtType.STORAGE,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1040,31 +1015,50 @@ async def async_setup_entry(
|
||||
for description in NET_CONSUMPTION_PHASE_SENSORS[use_phase]
|
||||
if phase is not None
|
||||
)
|
||||
# Add Current Transformer entities
|
||||
if envoy_data.ctmeters:
|
||||
# Add net consumption CT entities
|
||||
if ctmeter := envoy_data.ctmeter_consumption:
|
||||
entities.extend(
|
||||
EnvoyCTEntity(coordinator, description)
|
||||
for sensors in (
|
||||
CT_NET_CONSUMPTION_SENSORS,
|
||||
CT_PRODUCTION_SENSORS,
|
||||
CT_STORAGE_SENSORS,
|
||||
)
|
||||
for description in sensors
|
||||
if description.cttype in envoy_data.ctmeters
|
||||
EnvoyConsumptionCTEntity(coordinator, description)
|
||||
for description in CT_NET_CONSUMPTION_SENSORS
|
||||
if ctmeter.measurement_type == CtType.NET_CONSUMPTION
|
||||
)
|
||||
# Add Current Transformer phase entities
|
||||
if ctmeters_phases := envoy_data.ctmeters_phases:
|
||||
# For each net consumption ct phase reported add net consumption entities
|
||||
if phase_data := envoy_data.ctmeter_consumption_phases:
|
||||
entities.extend(
|
||||
EnvoyCTPhaseEntity(coordinator, description)
|
||||
for sensors in (
|
||||
CT_NET_CONSUMPTION_PHASE_SENSORS,
|
||||
CT_PRODUCTION_PHASE_SENSORS,
|
||||
CT_STORAGE_PHASE_SENSORS,
|
||||
)
|
||||
for phase, descriptions in sensors.items()
|
||||
for description in descriptions
|
||||
if (cttype := description.cttype) in ctmeters_phases
|
||||
and phase in ctmeters_phases[cttype]
|
||||
EnvoyConsumptionCTPhaseEntity(coordinator, description)
|
||||
for use_phase, phase in phase_data.items()
|
||||
for description in CT_NET_CONSUMPTION_PHASE_SENSORS[use_phase]
|
||||
if phase.measurement_type == CtType.NET_CONSUMPTION
|
||||
)
|
||||
# Add production CT entities
|
||||
if ctmeter := envoy_data.ctmeter_production:
|
||||
entities.extend(
|
||||
EnvoyProductionCTEntity(coordinator, description)
|
||||
for description in CT_PRODUCTION_SENSORS
|
||||
if ctmeter.measurement_type == CtType.PRODUCTION
|
||||
)
|
||||
# For each production ct phase reported add production ct entities
|
||||
if phase_data := envoy_data.ctmeter_production_phases:
|
||||
entities.extend(
|
||||
EnvoyProductionCTPhaseEntity(coordinator, description)
|
||||
for use_phase, phase in phase_data.items()
|
||||
for description in CT_PRODUCTION_PHASE_SENSORS[use_phase]
|
||||
if phase.measurement_type == CtType.PRODUCTION
|
||||
)
|
||||
# Add storage CT entities
|
||||
if ctmeter := envoy_data.ctmeter_storage:
|
||||
entities.extend(
|
||||
EnvoyStorageCTEntity(coordinator, description)
|
||||
for description in CT_STORAGE_SENSORS
|
||||
if ctmeter.measurement_type == CtType.STORAGE
|
||||
)
|
||||
# For each storage ct phase reported add storage ct entities
|
||||
if phase_data := envoy_data.ctmeter_storage_phases:
|
||||
entities.extend(
|
||||
EnvoyStorageCTPhaseEntity(coordinator, description)
|
||||
for use_phase, phase in phase_data.items()
|
||||
for description in CT_STORAGE_PHASE_SENSORS[use_phase]
|
||||
if phase.measurement_type == CtType.STORAGE
|
||||
)
|
||||
|
||||
if envoy_data.inverters:
|
||||
@@ -1251,8 +1245,8 @@ class EnvoyNetConsumptionPhaseEntity(EnvoySystemSensorEntity):
|
||||
return self.entity_description.value_fn(system_net_consumption)
|
||||
|
||||
|
||||
class EnvoyCTEntity(EnvoySystemSensorEntity):
|
||||
"""Envoy CT entity."""
|
||||
class EnvoyConsumptionCTEntity(EnvoySystemSensorEntity):
|
||||
"""Envoy net consumption CT entity."""
|
||||
|
||||
entity_description: EnvoyCTSensorEntityDescription
|
||||
|
||||
@@ -1261,13 +1255,13 @@ class EnvoyCTEntity(EnvoySystemSensorEntity):
|
||||
self,
|
||||
) -> int | float | str | CtType | CtMeterStatus | CtStatusFlags | None:
|
||||
"""Return the state of the CT sensor."""
|
||||
if (cttype := self.entity_description.cttype) not in self.data.ctmeters:
|
||||
if (ctmeter := self.data.ctmeter_consumption) is None:
|
||||
return None
|
||||
return self.entity_description.value_fn(self.data.ctmeters[cttype])
|
||||
return self.entity_description.value_fn(ctmeter)
|
||||
|
||||
|
||||
class EnvoyCTPhaseEntity(EnvoySystemSensorEntity):
|
||||
"""Envoy CT phase entity."""
|
||||
class EnvoyConsumptionCTPhaseEntity(EnvoySystemSensorEntity):
|
||||
"""Envoy net consumption CT phase entity."""
|
||||
|
||||
entity_description: EnvoyCTSensorEntityDescription
|
||||
|
||||
@@ -1278,14 +1272,78 @@ class EnvoyCTPhaseEntity(EnvoySystemSensorEntity):
|
||||
"""Return the state of the CT phase sensor."""
|
||||
if TYPE_CHECKING:
|
||||
assert self.entity_description.on_phase
|
||||
if (cttype := self.entity_description.cttype) not in self.data.ctmeters_phases:
|
||||
return None
|
||||
if (phase := self.entity_description.on_phase) not in self.data.ctmeters_phases[
|
||||
cttype
|
||||
]:
|
||||
if (ctmeter := self.data.ctmeter_consumption_phases) is None:
|
||||
return None
|
||||
return self.entity_description.value_fn(
|
||||
self.data.ctmeters_phases[cttype][phase]
|
||||
ctmeter[self.entity_description.on_phase]
|
||||
)
|
||||
|
||||
|
||||
class EnvoyProductionCTEntity(EnvoySystemSensorEntity):
|
||||
"""Envoy net consumption CT entity."""
|
||||
|
||||
entity_description: EnvoyCTSensorEntityDescription
|
||||
|
||||
@property
|
||||
def native_value(
|
||||
self,
|
||||
) -> int | float | str | CtType | CtMeterStatus | CtStatusFlags | None:
|
||||
"""Return the state of the CT sensor."""
|
||||
if (ctmeter := self.data.ctmeter_production) is None:
|
||||
return None
|
||||
return self.entity_description.value_fn(ctmeter)
|
||||
|
||||
|
||||
class EnvoyProductionCTPhaseEntity(EnvoySystemSensorEntity):
|
||||
"""Envoy net consumption CT phase entity."""
|
||||
|
||||
entity_description: EnvoyCTSensorEntityDescription
|
||||
|
||||
@property
|
||||
def native_value(
|
||||
self,
|
||||
) -> int | float | str | CtType | CtMeterStatus | CtStatusFlags | None:
|
||||
"""Return the state of the CT phase sensor."""
|
||||
if TYPE_CHECKING:
|
||||
assert self.entity_description.on_phase
|
||||
if (ctmeter := self.data.ctmeter_production_phases) is None:
|
||||
return None
|
||||
return self.entity_description.value_fn(
|
||||
ctmeter[self.entity_description.on_phase]
|
||||
)
|
||||
|
||||
|
||||
class EnvoyStorageCTEntity(EnvoySystemSensorEntity):
|
||||
"""Envoy net storage CT entity."""
|
||||
|
||||
entity_description: EnvoyCTSensorEntityDescription
|
||||
|
||||
@property
|
||||
def native_value(
|
||||
self,
|
||||
) -> int | float | str | CtType | CtMeterStatus | CtStatusFlags | None:
|
||||
"""Return the state of the CT sensor."""
|
||||
if (ctmeter := self.data.ctmeter_storage) is None:
|
||||
return None
|
||||
return self.entity_description.value_fn(ctmeter)
|
||||
|
||||
|
||||
class EnvoyStorageCTPhaseEntity(EnvoySystemSensorEntity):
|
||||
"""Envoy net storage CT phase entity."""
|
||||
|
||||
entity_description: EnvoyCTSensorEntityDescription
|
||||
|
||||
@property
|
||||
def native_value(
|
||||
self,
|
||||
) -> int | float | str | CtType | CtMeterStatus | CtStatusFlags | None:
|
||||
"""Return the state of the CT phase sensor."""
|
||||
if TYPE_CHECKING:
|
||||
assert self.entity_description.on_phase
|
||||
if (ctmeter := self.data.ctmeter_storage_phases) is None:
|
||||
return None
|
||||
return self.entity_description.value_fn(
|
||||
ctmeter[self.entity_description.on_phase]
|
||||
)
|
||||
|
||||
|
||||
|
@@ -22,23 +22,19 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components import zeroconf
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_ESPHOME,
|
||||
SOURCE_IGNORE,
|
||||
SOURCE_REAUTH,
|
||||
SOURCE_RECONFIGURE,
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
FlowType,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import AbortFlow, FlowResultType
|
||||
from homeassistant.helpers import discovery_flow
|
||||
from homeassistant.data_entry_flow import AbortFlow
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
from homeassistant.helpers.service_info.esphome import ESPHomeServiceInfo
|
||||
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
|
||||
from homeassistant.helpers.service_info.mqtt import MqttServiceInfo
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
@@ -79,7 +75,6 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize flow."""
|
||||
self._host: str | None = None
|
||||
self._connected_address: str | None = None
|
||||
self.__name: str | None = None
|
||||
self._port: int | None = None
|
||||
self._password: str | None = None
|
||||
@@ -503,55 +498,18 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
await self.hass.config_entries.async_remove(
|
||||
self._entry_with_name_conflict.entry_id
|
||||
)
|
||||
return await self._async_create_entry()
|
||||
return self._async_create_entry()
|
||||
|
||||
async def _async_create_entry(self) -> ConfigFlowResult:
|
||||
@callback
|
||||
def _async_create_entry(self) -> ConfigFlowResult:
|
||||
"""Create the config entry."""
|
||||
assert self._name is not None
|
||||
assert self._device_info is not None
|
||||
|
||||
# Check if Z-Wave capabilities are present and start discovery flow
|
||||
next_flow_id: str | None = None
|
||||
if self._device_info.zwave_proxy_feature_flags:
|
||||
assert self._connected_address is not None
|
||||
assert self._port is not None
|
||||
|
||||
# Start Z-Wave discovery flow and get the flow ID
|
||||
zwave_result = await self.hass.config_entries.flow.async_init(
|
||||
"zwave_js",
|
||||
context={
|
||||
"source": SOURCE_ESPHOME,
|
||||
"discovery_key": discovery_flow.DiscoveryKey(
|
||||
domain=DOMAIN,
|
||||
key=self._device_info.mac_address,
|
||||
version=1,
|
||||
),
|
||||
},
|
||||
data=ESPHomeServiceInfo(
|
||||
name=self._device_info.name,
|
||||
zwave_home_id=self._device_info.zwave_home_id or None,
|
||||
ip_address=self._connected_address,
|
||||
port=self._port,
|
||||
noise_psk=self._noise_psk,
|
||||
),
|
||||
)
|
||||
if zwave_result["type"] in (
|
||||
FlowResultType.ABORT,
|
||||
FlowResultType.CREATE_ENTRY,
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"Unable to continue created Z-Wave JS config flow: %s", zwave_result
|
||||
)
|
||||
else:
|
||||
next_flow_id = zwave_result["flow_id"]
|
||||
|
||||
return self.async_create_entry(
|
||||
title=self._name,
|
||||
data=self._async_make_config_data(),
|
||||
options={
|
||||
CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
|
||||
},
|
||||
next_flow=(FlowType.CONFIG_FLOW, next_flow_id) if next_flow_id else None,
|
||||
)
|
||||
|
||||
@callback
|
||||
@@ -598,7 +556,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
if entry.data.get(CONF_DEVICE_NAME) == self._device_name:
|
||||
self._entry_with_name_conflict = entry
|
||||
return await self.async_step_name_conflict()
|
||||
return await self._async_create_entry()
|
||||
return self._async_create_entry()
|
||||
|
||||
async def _async_reauth_validated_connection(self) -> ConfigFlowResult:
|
||||
"""Handle reauth validated connection."""
|
||||
@@ -745,7 +703,6 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
try:
|
||||
await cli.connect()
|
||||
self._device_info = await cli.device_info()
|
||||
self._connected_address = cli.connected_address
|
||||
except InvalidAuthAPIError:
|
||||
return ERROR_INVALID_PASSWORD_AUTH
|
||||
except RequiresEncryptionAPIError:
|
||||
|
@@ -17,9 +17,9 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==41.13.0",
|
||||
"aioesphomeapi==41.11.0",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.4.0"
|
||||
"bleak-esphome==3.3.0"
|
||||
],
|
||||
"zeroconf": ["_esphomelib._tcp.local."]
|
||||
}
|
||||
|
@@ -29,12 +29,7 @@ from homeassistant.components.climate import (
|
||||
ClimateEntityFeature,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_MODE,
|
||||
ATTR_TEMPERATURE,
|
||||
PRECISION_TENTHS,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.const import ATTR_MODE, PRECISION_TENTHS, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
@@ -248,7 +243,7 @@ class EvoZone(EvoChild, EvoClimateEntity):
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set a new target temperature."""
|
||||
|
||||
temperature = kwargs[ATTR_TEMPERATURE]
|
||||
temperature = kwargs["temperature"]
|
||||
|
||||
if (until := kwargs.get("until")) is None:
|
||||
if self._evo_device.mode == EvoZoneMode.TEMPORARY_OVERRIDE:
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user