mirror of
https://github.com/home-assistant/core.git
synced 2025-10-13 13:49:30 +00:00
Compare commits
4 Commits
compensati
...
llm-task-a
Author | SHA1 | Date | |
---|---|---|---|
![]() |
77230c774e | ||
![]() |
17a5815ca1 | ||
![]() |
a8d4caab01 | ||
![]() |
2be6acec03 |
5
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
5
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,14 +1,15 @@
|
|||||||
name: Report an issue with Home Assistant Core
|
name: Report an issue with Home Assistant Core
|
||||||
description: Report an issue with Home Assistant Core.
|
description: Report an issue with Home Assistant Core.
|
||||||
|
type: Bug
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
This issue form is for reporting bugs only!
|
This issue form is for reporting bugs only!
|
||||||
|
|
||||||
If you have a feature or enhancement request, please [request them here instead][fr].
|
If you have a feature or enhancement request, please use the [feature request][fr] section of our [Community Forum][fr].
|
||||||
|
|
||||||
[fr]: https://github.com/orgs/home-assistant/discussions
|
[fr]: https://community.home-assistant.io/c/feature-requests
|
||||||
- type: textarea
|
- type: textarea
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
4
.github/ISSUE_TEMPLATE/config.yml
vendored
4
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -10,8 +10,8 @@ contact_links:
|
|||||||
url: https://www.home-assistant.io/help
|
url: https://www.home-assistant.io/help
|
||||||
about: We use GitHub for tracking bugs, check our website for resources on getting help.
|
about: We use GitHub for tracking bugs, check our website for resources on getting help.
|
||||||
- name: Feature Request
|
- name: Feature Request
|
||||||
url: https://github.com/orgs/home-assistant/discussions
|
url: https://community.home-assistant.io/c/feature-requests
|
||||||
about: Please use this link to request new features or enhancements to existing features.
|
about: Please use our Community Forum for making feature requests.
|
||||||
- name: I'm unsure where to go
|
- name: I'm unsure where to go
|
||||||
url: https://www.home-assistant.io/join-chat
|
url: https://www.home-assistant.io/join-chat
|
||||||
about: If you are unsure where to go, then joining our chat is recommended; Just ask!
|
about: If you are unsure where to go, then joining our chat is recommended; Just ask!
|
||||||
|
1235
.github/copilot-instructions.md
vendored
1235
.github/copilot-instructions.md
vendored
File diff suppressed because it is too large
Load Diff
8
.github/workflows/builder.yml
vendored
8
.github/workflows/builder.yml
vendored
@@ -94,7 +94,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Download nightly wheels of frontend
|
- name: Download nightly wheels of frontend
|
||||||
if: needs.init.outputs.channel == 'dev'
|
if: needs.init.outputs.channel == 'dev'
|
||||||
uses: dawidd6/action-download-artifact@v11
|
uses: dawidd6/action-download-artifact@v10
|
||||||
with:
|
with:
|
||||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||||
repo: home-assistant/frontend
|
repo: home-assistant/frontend
|
||||||
@@ -105,10 +105,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Download nightly wheels of intents
|
- name: Download nightly wheels of intents
|
||||||
if: needs.init.outputs.channel == 'dev'
|
if: needs.init.outputs.channel == 'dev'
|
||||||
uses: dawidd6/action-download-artifact@v11
|
uses: dawidd6/action-download-artifact@v10
|
||||||
with:
|
with:
|
||||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||||
repo: OHF-Voice/intents-package
|
repo: home-assistant/intents-package
|
||||||
branch: main
|
branch: main
|
||||||
workflow: nightly.yaml
|
workflow: nightly.yaml
|
||||||
workflow_conclusion: success
|
workflow_conclusion: success
|
||||||
@@ -324,7 +324,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
|
|
||||||
- name: Install Cosign
|
- name: Install Cosign
|
||||||
uses: sigstore/cosign-installer@v3.9.1
|
uses: sigstore/cosign-installer@v3.8.2
|
||||||
with:
|
with:
|
||||||
cosign-release: "v2.2.3"
|
cosign-release: "v2.2.3"
|
||||||
|
|
||||||
|
4
.github/workflows/ci.yaml
vendored
4
.github/workflows/ci.yaml
vendored
@@ -37,10 +37,10 @@ on:
|
|||||||
type: boolean
|
type: boolean
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CACHE_VERSION: 3
|
CACHE_VERSION: 2
|
||||||
UV_CACHE_VERSION: 1
|
UV_CACHE_VERSION: 1
|
||||||
MYPY_CACHE_VERSION: 1
|
MYPY_CACHE_VERSION: 1
|
||||||
HA_SHORT_VERSION: "2025.8"
|
HA_SHORT_VERSION: "2025.7"
|
||||||
DEFAULT_PYTHON: "3.13"
|
DEFAULT_PYTHON: "3.13"
|
||||||
ALL_PYTHON_VERSIONS: "['3.13']"
|
ALL_PYTHON_VERSIONS: "['3.13']"
|
||||||
# 10.3 is the oldest supported version
|
# 10.3 is the oldest supported version
|
||||||
|
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -24,11 +24,11 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v3.29.1
|
uses: github/codeql-action/init@v3.29.0
|
||||||
with:
|
with:
|
||||||
languages: python
|
languages: python
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v3.29.1
|
uses: github/codeql-action/analyze@v3.29.0
|
||||||
with:
|
with:
|
||||||
category: "/language:python"
|
category: "/language:python"
|
||||||
|
6
.gitignore
vendored
6
.gitignore
vendored
@@ -137,8 +137,4 @@ tmp_cache
|
|||||||
.ropeproject
|
.ropeproject
|
||||||
|
|
||||||
# Will be created from script/split_tests.py
|
# Will be created from script/split_tests.py
|
||||||
pytest_buckets.txt
|
pytest_buckets.txt
|
||||||
|
|
||||||
# AI tooling
|
|
||||||
.claude
|
|
||||||
|
|
@@ -1,6 +1,6 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.12.1
|
rev: v0.11.12
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff-check
|
- id: ruff-check
|
||||||
args:
|
args:
|
||||||
|
@@ -67,7 +67,6 @@ homeassistant.components.alert.*
|
|||||||
homeassistant.components.alexa.*
|
homeassistant.components.alexa.*
|
||||||
homeassistant.components.alexa_devices.*
|
homeassistant.components.alexa_devices.*
|
||||||
homeassistant.components.alpha_vantage.*
|
homeassistant.components.alpha_vantage.*
|
||||||
homeassistant.components.altruist.*
|
|
||||||
homeassistant.components.amazon_polly.*
|
homeassistant.components.amazon_polly.*
|
||||||
homeassistant.components.amberelectric.*
|
homeassistant.components.amberelectric.*
|
||||||
homeassistant.components.ambient_network.*
|
homeassistant.components.ambient_network.*
|
||||||
@@ -503,7 +502,6 @@ homeassistant.components.tautulli.*
|
|||||||
homeassistant.components.tcp.*
|
homeassistant.components.tcp.*
|
||||||
homeassistant.components.technove.*
|
homeassistant.components.technove.*
|
||||||
homeassistant.components.tedee.*
|
homeassistant.components.tedee.*
|
||||||
homeassistant.components.telegram_bot.*
|
|
||||||
homeassistant.components.text.*
|
homeassistant.components.text.*
|
||||||
homeassistant.components.thethingsnetwork.*
|
homeassistant.components.thethingsnetwork.*
|
||||||
homeassistant.components.threshold.*
|
homeassistant.components.threshold.*
|
||||||
|
16
CODEOWNERS
generated
16
CODEOWNERS
generated
@@ -93,8 +93,6 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh
|
/tests/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh
|
||||||
/homeassistant/components/alexa_devices/ @chemelli74
|
/homeassistant/components/alexa_devices/ @chemelli74
|
||||||
/tests/components/alexa_devices/ @chemelli74
|
/tests/components/alexa_devices/ @chemelli74
|
||||||
/homeassistant/components/altruist/ @airalab @LoSk-p
|
|
||||||
/tests/components/altruist/ @airalab @LoSk-p
|
|
||||||
/homeassistant/components/amazon_polly/ @jschlyter
|
/homeassistant/components/amazon_polly/ @jschlyter
|
||||||
/homeassistant/components/amberelectric/ @madpilot
|
/homeassistant/components/amberelectric/ @madpilot
|
||||||
/tests/components/amberelectric/ @madpilot
|
/tests/components/amberelectric/ @madpilot
|
||||||
@@ -331,8 +329,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/demo/ @home-assistant/core
|
/tests/components/demo/ @home-assistant/core
|
||||||
/homeassistant/components/denonavr/ @ol-iver @starkillerOG
|
/homeassistant/components/denonavr/ @ol-iver @starkillerOG
|
||||||
/tests/components/denonavr/ @ol-iver @starkillerOG
|
/tests/components/denonavr/ @ol-iver @starkillerOG
|
||||||
/homeassistant/components/derivative/ @afaucogney @karwosts
|
/homeassistant/components/derivative/ @afaucogney
|
||||||
/tests/components/derivative/ @afaucogney @karwosts
|
/tests/components/derivative/ @afaucogney
|
||||||
/homeassistant/components/devialet/ @fwestenberg
|
/homeassistant/components/devialet/ @fwestenberg
|
||||||
/tests/components/devialet/ @fwestenberg
|
/tests/components/devialet/ @fwestenberg
|
||||||
/homeassistant/components/device_automation/ @home-assistant/core
|
/homeassistant/components/device_automation/ @home-assistant/core
|
||||||
@@ -788,6 +786,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/jellyfin/ @RunC0deRun @ctalkington
|
/tests/components/jellyfin/ @RunC0deRun @ctalkington
|
||||||
/homeassistant/components/jewish_calendar/ @tsvi
|
/homeassistant/components/jewish_calendar/ @tsvi
|
||||||
/tests/components/jewish_calendar/ @tsvi
|
/tests/components/jewish_calendar/ @tsvi
|
||||||
|
/homeassistant/components/juicenet/ @jesserockz
|
||||||
|
/tests/components/juicenet/ @jesserockz
|
||||||
/homeassistant/components/justnimbus/ @kvanzuijlen
|
/homeassistant/components/justnimbus/ @kvanzuijlen
|
||||||
/tests/components/justnimbus/ @kvanzuijlen
|
/tests/components/justnimbus/ @kvanzuijlen
|
||||||
/homeassistant/components/jvc_projector/ @SteveEasley @msavazzi
|
/homeassistant/components/jvc_projector/ @SteveEasley @msavazzi
|
||||||
@@ -1169,8 +1169,6 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/ping/ @jpbede
|
/tests/components/ping/ @jpbede
|
||||||
/homeassistant/components/plaato/ @JohNan
|
/homeassistant/components/plaato/ @JohNan
|
||||||
/tests/components/plaato/ @JohNan
|
/tests/components/plaato/ @JohNan
|
||||||
/homeassistant/components/playstation_network/ @jackjpowell @tr4nt0r
|
|
||||||
/tests/components/playstation_network/ @jackjpowell @tr4nt0r
|
|
||||||
/homeassistant/components/plex/ @jjlawren
|
/homeassistant/components/plex/ @jjlawren
|
||||||
/tests/components/plex/ @jjlawren
|
/tests/components/plex/ @jjlawren
|
||||||
/homeassistant/components/plugwise/ @CoMPaTech @bouwew
|
/homeassistant/components/plugwise/ @CoMPaTech @bouwew
|
||||||
@@ -1553,8 +1551,6 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/technove/ @Moustachauve
|
/tests/components/technove/ @Moustachauve
|
||||||
/homeassistant/components/tedee/ @patrickhilker @zweckj
|
/homeassistant/components/tedee/ @patrickhilker @zweckj
|
||||||
/tests/components/tedee/ @patrickhilker @zweckj
|
/tests/components/tedee/ @patrickhilker @zweckj
|
||||||
/homeassistant/components/telegram_bot/ @hanwg
|
|
||||||
/tests/components/telegram_bot/ @hanwg
|
|
||||||
/homeassistant/components/tellduslive/ @fredrike
|
/homeassistant/components/tellduslive/ @fredrike
|
||||||
/tests/components/tellduslive/ @fredrike
|
/tests/components/tellduslive/ @fredrike
|
||||||
/homeassistant/components/template/ @Petro31 @home-assistant/core
|
/homeassistant/components/template/ @Petro31 @home-assistant/core
|
||||||
@@ -1584,8 +1580,6 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/tile/ @bachya
|
/tests/components/tile/ @bachya
|
||||||
/homeassistant/components/tilt_ble/ @apt-itude
|
/homeassistant/components/tilt_ble/ @apt-itude
|
||||||
/tests/components/tilt_ble/ @apt-itude
|
/tests/components/tilt_ble/ @apt-itude
|
||||||
/homeassistant/components/tilt_pi/ @michaelheyman
|
|
||||||
/tests/components/tilt_pi/ @michaelheyman
|
|
||||||
/homeassistant/components/time/ @home-assistant/core
|
/homeassistant/components/time/ @home-assistant/core
|
||||||
/tests/components/time/ @home-assistant/core
|
/tests/components/time/ @home-assistant/core
|
||||||
/homeassistant/components/time_date/ @fabaff
|
/homeassistant/components/time_date/ @fabaff
|
||||||
@@ -1674,8 +1668,6 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/vallox/ @andre-richter @slovdahl @viiru- @yozik04
|
/tests/components/vallox/ @andre-richter @slovdahl @viiru- @yozik04
|
||||||
/homeassistant/components/valve/ @home-assistant/core
|
/homeassistant/components/valve/ @home-assistant/core
|
||||||
/tests/components/valve/ @home-assistant/core
|
/tests/components/valve/ @home-assistant/core
|
||||||
/homeassistant/components/vegehub/ @ghowevege
|
|
||||||
/tests/components/vegehub/ @ghowevege
|
|
||||||
/homeassistant/components/velbus/ @Cereal2nd @brefra
|
/homeassistant/components/velbus/ @Cereal2nd @brefra
|
||||||
/tests/components/velbus/ @Cereal2nd @brefra
|
/tests/components/velbus/ @Cereal2nd @brefra
|
||||||
/homeassistant/components/velux/ @Julius2342 @DeerMaximum @pawlizio
|
/homeassistant/components/velux/ @Julius2342 @DeerMaximum @pawlizio
|
||||||
|
@@ -38,7 +38,8 @@ def validate_python() -> None:
|
|||||||
|
|
||||||
def ensure_config_path(config_dir: str) -> None:
|
def ensure_config_path(config_dir: str) -> None:
|
||||||
"""Validate the configuration directory."""
|
"""Validate the configuration directory."""
|
||||||
from . import config as config_util # noqa: PLC0415
|
# pylint: disable-next=import-outside-toplevel
|
||||||
|
from . import config as config_util
|
||||||
|
|
||||||
lib_dir = os.path.join(config_dir, "deps")
|
lib_dir = os.path.join(config_dir, "deps")
|
||||||
|
|
||||||
@@ -79,7 +80,8 @@ def ensure_config_path(config_dir: str) -> None:
|
|||||||
|
|
||||||
def get_arguments() -> argparse.Namespace:
|
def get_arguments() -> argparse.Namespace:
|
||||||
"""Get parsed passed in arguments."""
|
"""Get parsed passed in arguments."""
|
||||||
from . import config as config_util # noqa: PLC0415
|
# pylint: disable-next=import-outside-toplevel
|
||||||
|
from . import config as config_util
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Home Assistant: Observe, Control, Automate.",
|
description="Home Assistant: Observe, Control, Automate.",
|
||||||
@@ -175,7 +177,8 @@ def main() -> int:
|
|||||||
validate_os()
|
validate_os()
|
||||||
|
|
||||||
if args.script is not None:
|
if args.script is not None:
|
||||||
from . import scripts # noqa: PLC0415
|
# pylint: disable-next=import-outside-toplevel
|
||||||
|
from . import scripts
|
||||||
|
|
||||||
return scripts.run(args.script)
|
return scripts.run(args.script)
|
||||||
|
|
||||||
@@ -185,7 +188,8 @@ def main() -> int:
|
|||||||
|
|
||||||
ensure_config_path(config_dir)
|
ensure_config_path(config_dir)
|
||||||
|
|
||||||
from . import config, runner # noqa: PLC0415
|
# pylint: disable-next=import-outside-toplevel
|
||||||
|
from . import config, runner
|
||||||
|
|
||||||
safe_mode = config.safe_mode_enabled(config_dir)
|
safe_mode = config.safe_mode_enabled(config_dir)
|
||||||
|
|
||||||
|
@@ -52,28 +52,28 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
|
|
||||||
def _generate_secret() -> str:
|
def _generate_secret() -> str:
|
||||||
"""Generate a secret."""
|
"""Generate a secret."""
|
||||||
import pyotp # noqa: PLC0415
|
import pyotp # pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
return str(pyotp.random_base32())
|
return str(pyotp.random_base32())
|
||||||
|
|
||||||
|
|
||||||
def _generate_random() -> int:
|
def _generate_random() -> int:
|
||||||
"""Generate a 32 digit number."""
|
"""Generate a 32 digit number."""
|
||||||
import pyotp # noqa: PLC0415
|
import pyotp # pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
return int(pyotp.random_base32(length=32, chars=list("1234567890")))
|
return int(pyotp.random_base32(length=32, chars=list("1234567890")))
|
||||||
|
|
||||||
|
|
||||||
def _generate_otp(secret: str, count: int) -> str:
|
def _generate_otp(secret: str, count: int) -> str:
|
||||||
"""Generate one time password."""
|
"""Generate one time password."""
|
||||||
import pyotp # noqa: PLC0415
|
import pyotp # pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
return str(pyotp.HOTP(secret).at(count))
|
return str(pyotp.HOTP(secret).at(count))
|
||||||
|
|
||||||
|
|
||||||
def _verify_otp(secret: str, otp: str, count: int) -> bool:
|
def _verify_otp(secret: str, otp: str, count: int) -> bool:
|
||||||
"""Verify one time password."""
|
"""Verify one time password."""
|
||||||
import pyotp # noqa: PLC0415
|
import pyotp # pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
return bool(pyotp.HOTP(secret).verify(otp, count))
|
return bool(pyotp.HOTP(secret).verify(otp, count))
|
||||||
|
|
||||||
|
@@ -37,7 +37,7 @@ DUMMY_SECRET = "FPPTH34D4E3MI2HG"
|
|||||||
|
|
||||||
def _generate_qr_code(data: str) -> str:
|
def _generate_qr_code(data: str) -> str:
|
||||||
"""Generate a base64 PNG string represent QR Code image of data."""
|
"""Generate a base64 PNG string represent QR Code image of data."""
|
||||||
import pyqrcode # noqa: PLC0415
|
import pyqrcode # pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
qr_code = pyqrcode.create(data)
|
qr_code = pyqrcode.create(data)
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ def _generate_qr_code(data: str) -> str:
|
|||||||
|
|
||||||
def _generate_secret_and_qr_code(username: str) -> tuple[str, str, str]:
|
def _generate_secret_and_qr_code(username: str) -> tuple[str, str, str]:
|
||||||
"""Generate a secret, url, and QR code."""
|
"""Generate a secret, url, and QR code."""
|
||||||
import pyotp # noqa: PLC0415
|
import pyotp # pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
ota_secret = pyotp.random_base32()
|
ota_secret = pyotp.random_base32()
|
||||||
url = pyotp.totp.TOTP(ota_secret).provisioning_uri(
|
url = pyotp.totp.TOTP(ota_secret).provisioning_uri(
|
||||||
@@ -107,7 +107,7 @@ class TotpAuthModule(MultiFactorAuthModule):
|
|||||||
|
|
||||||
def _add_ota_secret(self, user_id: str, secret: str | None = None) -> str:
|
def _add_ota_secret(self, user_id: str, secret: str | None = None) -> str:
|
||||||
"""Create a ota_secret for user."""
|
"""Create a ota_secret for user."""
|
||||||
import pyotp # noqa: PLC0415
|
import pyotp # pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
ota_secret: str = secret or pyotp.random_base32()
|
ota_secret: str = secret or pyotp.random_base32()
|
||||||
|
|
||||||
@@ -163,7 +163,7 @@ class TotpAuthModule(MultiFactorAuthModule):
|
|||||||
|
|
||||||
def _validate_2fa(self, user_id: str, code: str) -> bool:
|
def _validate_2fa(self, user_id: str, code: str) -> bool:
|
||||||
"""Validate two factor authentication code."""
|
"""Validate two factor authentication code."""
|
||||||
import pyotp # noqa: PLC0415
|
import pyotp # pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
if (ota_secret := self._users.get(user_id)) is None: # type: ignore[union-attr]
|
if (ota_secret := self._users.get(user_id)) is None: # type: ignore[union-attr]
|
||||||
# even we cannot find user, we still do verify
|
# even we cannot find user, we still do verify
|
||||||
@@ -196,7 +196,7 @@ class TotpSetupFlow(SetupFlow[TotpAuthModule]):
|
|||||||
Return self.async_show_form(step_id='init') if user_input is None.
|
Return self.async_show_form(step_id='init') if user_input is None.
|
||||||
Return self.async_create_entry(data={'result': result}) if finish.
|
Return self.async_create_entry(data={'result': result}) if finish.
|
||||||
"""
|
"""
|
||||||
import pyotp # noqa: PLC0415
|
import pyotp # pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
|
@@ -75,6 +75,7 @@ from .core_config import async_process_ha_core_config
|
|||||||
from .exceptions import HomeAssistantError
|
from .exceptions import HomeAssistantError
|
||||||
from .helpers import (
|
from .helpers import (
|
||||||
area_registry,
|
area_registry,
|
||||||
|
backup,
|
||||||
category_registry,
|
category_registry,
|
||||||
config_validation as cv,
|
config_validation as cv,
|
||||||
device_registry,
|
device_registry,
|
||||||
@@ -88,7 +89,6 @@ from .helpers import (
|
|||||||
restore_state,
|
restore_state,
|
||||||
template,
|
template,
|
||||||
translation,
|
translation,
|
||||||
trigger,
|
|
||||||
)
|
)
|
||||||
from .helpers.dispatcher import async_dispatcher_send_internal
|
from .helpers.dispatcher import async_dispatcher_send_internal
|
||||||
from .helpers.storage import get_internal_store_manager
|
from .helpers.storage import get_internal_store_manager
|
||||||
@@ -394,7 +394,7 @@ async def async_setup_hass(
|
|||||||
|
|
||||||
def open_hass_ui(hass: core.HomeAssistant) -> None:
|
def open_hass_ui(hass: core.HomeAssistant) -> None:
|
||||||
"""Open the UI."""
|
"""Open the UI."""
|
||||||
import webbrowser # noqa: PLC0415
|
import webbrowser # pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
if hass.config.api is None or "frontend" not in hass.config.components:
|
if hass.config.api is None or "frontend" not in hass.config.components:
|
||||||
_LOGGER.warning("Cannot launch the UI because frontend not loaded")
|
_LOGGER.warning("Cannot launch the UI because frontend not loaded")
|
||||||
@@ -452,7 +452,6 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> None:
|
|||||||
create_eager_task(restore_state.async_load(hass)),
|
create_eager_task(restore_state.async_load(hass)),
|
||||||
create_eager_task(hass.config_entries.async_initialize()),
|
create_eager_task(hass.config_entries.async_initialize()),
|
||||||
create_eager_task(async_get_system_info(hass)),
|
create_eager_task(async_get_system_info(hass)),
|
||||||
create_eager_task(trigger.async_setup(hass)),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -562,7 +561,8 @@ async def async_enable_logging(
|
|||||||
|
|
||||||
if not log_no_color:
|
if not log_no_color:
|
||||||
try:
|
try:
|
||||||
from colorlog import ColoredFormatter # noqa: PLC0415
|
# pylint: disable-next=import-outside-toplevel
|
||||||
|
from colorlog import ColoredFormatter
|
||||||
|
|
||||||
# basicConfig must be called after importing colorlog in order to
|
# basicConfig must be called after importing colorlog in order to
|
||||||
# ensure that the handlers it sets up wraps the correct streams.
|
# ensure that the handlers it sets up wraps the correct streams.
|
||||||
@@ -879,6 +879,10 @@ async def _async_set_up_integrations(
|
|||||||
if "recorder" in all_domains:
|
if "recorder" in all_domains:
|
||||||
recorder.async_initialize_recorder(hass)
|
recorder.async_initialize_recorder(hass)
|
||||||
|
|
||||||
|
# Initialize backup
|
||||||
|
if "backup" in all_domains:
|
||||||
|
backup.async_initialize_backup(hass)
|
||||||
|
|
||||||
stages: list[tuple[str, set[str], int | None]] = [
|
stages: list[tuple[str, set[str], int | None]] = [
|
||||||
*(
|
*(
|
||||||
(name, domain_group, timeout)
|
(name, domain_group, timeout)
|
||||||
|
@@ -1,11 +1,5 @@
|
|||||||
{
|
{
|
||||||
"domain": "sony",
|
"domain": "sony",
|
||||||
"name": "Sony",
|
"name": "Sony",
|
||||||
"integrations": [
|
"integrations": ["braviatv", "ps4", "sony_projector", "songpal"]
|
||||||
"braviatv",
|
|
||||||
"ps4",
|
|
||||||
"sony_projector",
|
|
||||||
"songpal",
|
|
||||||
"playstation_network"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"domain": "switchbot",
|
"domain": "switchbot",
|
||||||
"name": "SwitchBot",
|
"name": "SwitchBot",
|
||||||
"integrations": ["switchbot", "switchbot_cloud"],
|
"integrations": ["switchbot", "switchbot_cloud"]
|
||||||
"iot_standards": ["matter"]
|
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"domain": "tilt",
|
|
||||||
"name": "Tilt",
|
|
||||||
"integrations": ["tilt_ble", "tilt_pi"]
|
|
||||||
}
|
|
@@ -185,7 +185,6 @@ FORECAST_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
|
|||||||
keys=[AOD_TOWN, AOD_FORECAST_DAILY, AOD_FORECAST_CURRENT, AOD_WIND_DIRECTION],
|
keys=[AOD_TOWN, AOD_FORECAST_DAILY, AOD_FORECAST_CURRENT, AOD_WIND_DIRECTION],
|
||||||
name="Daily forecast wind bearing",
|
name="Daily forecast wind bearing",
|
||||||
native_unit_of_measurement=DEGREE,
|
native_unit_of_measurement=DEGREE,
|
||||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
|
||||||
),
|
),
|
||||||
AemetSensorEntityDescription(
|
AemetSensorEntityDescription(
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
@@ -193,7 +192,6 @@ FORECAST_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
|
|||||||
keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_WIND_DIRECTION],
|
keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_WIND_DIRECTION],
|
||||||
name="Hourly forecast wind bearing",
|
name="Hourly forecast wind bearing",
|
||||||
native_unit_of_measurement=DEGREE,
|
native_unit_of_measurement=DEGREE,
|
||||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
|
||||||
),
|
),
|
||||||
AemetSensorEntityDescription(
|
AemetSensorEntityDescription(
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
@@ -336,8 +334,7 @@ WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
|
|||||||
keys=[AOD_WEATHER, AOD_WIND_DIRECTION],
|
keys=[AOD_WEATHER, AOD_WIND_DIRECTION],
|
||||||
name="Wind bearing",
|
name="Wind bearing",
|
||||||
native_unit_of_measurement=DEGREE,
|
native_unit_of_measurement=DEGREE,
|
||||||
state_class=SensorStateClass.MEASUREMENT_ANGLE,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
|
||||||
),
|
),
|
||||||
AemetSensorEntityDescription(
|
AemetSensorEntityDescription(
|
||||||
key=ATTR_API_WIND_MAX_SPEED,
|
key=ATTR_API_WIND_MAX_SPEED,
|
||||||
|
@@ -5,7 +5,6 @@ import logging
|
|||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import ATTR_ENTITY_ID
|
|
||||||
from homeassistant.core import (
|
from homeassistant.core import (
|
||||||
HassJobType,
|
HassJobType,
|
||||||
HomeAssistant,
|
HomeAssistant,
|
||||||
@@ -18,26 +17,17 @@ from homeassistant.helpers import config_validation as cv, storage
|
|||||||
from homeassistant.helpers.entity_component import EntityComponent
|
from homeassistant.helpers.entity_component import EntityComponent
|
||||||
from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType
|
from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType
|
||||||
|
|
||||||
from .const import (
|
from .const import DATA_COMPONENT, DATA_PREFERENCES, DOMAIN
|
||||||
ATTR_INSTRUCTIONS,
|
|
||||||
ATTR_TASK_NAME,
|
|
||||||
DATA_COMPONENT,
|
|
||||||
DATA_PREFERENCES,
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_GENERATE_DATA,
|
|
||||||
AITaskEntityFeature,
|
|
||||||
)
|
|
||||||
from .entity import AITaskEntity
|
from .entity import AITaskEntity
|
||||||
from .http import async_setup as async_setup_http
|
from .http import async_setup as async_setup_conversation_http
|
||||||
from .task import GenDataTask, GenDataTaskResult, async_generate_data
|
from .task import GenTextTask, GenTextTaskResult, async_generate_text
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DOMAIN",
|
"DOMAIN",
|
||||||
"AITaskEntity",
|
"AITaskEntity",
|
||||||
"AITaskEntityFeature",
|
"GenTextTask",
|
||||||
"GenDataTask",
|
"GenTextTaskResult",
|
||||||
"GenDataTaskResult",
|
"async_generate_text",
|
||||||
"async_generate_data",
|
|
||||||
"async_setup",
|
"async_setup",
|
||||||
"async_setup_entry",
|
"async_setup_entry",
|
||||||
"async_unload_entry",
|
"async_unload_entry",
|
||||||
@@ -54,16 +44,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
hass.data[DATA_COMPONENT] = entity_component
|
hass.data[DATA_COMPONENT] = entity_component
|
||||||
hass.data[DATA_PREFERENCES] = AITaskPreferences(hass)
|
hass.data[DATA_PREFERENCES] = AITaskPreferences(hass)
|
||||||
await hass.data[DATA_PREFERENCES].async_load()
|
await hass.data[DATA_PREFERENCES].async_load()
|
||||||
async_setup_http(hass)
|
async_setup_conversation_http(hass)
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
SERVICE_GENERATE_DATA,
|
"generate_text",
|
||||||
async_service_generate_data,
|
async_service_generate_text,
|
||||||
schema=vol.Schema(
|
schema=vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required(ATTR_TASK_NAME): cv.string,
|
vol.Required("task_name"): cv.string,
|
||||||
vol.Optional(ATTR_ENTITY_ID): cv.entity_id,
|
vol.Optional("entity_id"): cv.entity_id,
|
||||||
vol.Required(ATTR_INSTRUCTIONS): cv.string,
|
vol.Required("instructions"): cv.string,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
supports_response=SupportsResponse.ONLY,
|
supports_response=SupportsResponse.ONLY,
|
||||||
@@ -82,18 +72,16 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
|
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
|
||||||
|
|
||||||
|
|
||||||
async def async_service_generate_data(call: ServiceCall) -> ServiceResponse:
|
async def async_service_generate_text(call: ServiceCall) -> ServiceResponse:
|
||||||
"""Run the run task service."""
|
"""Run the run task service."""
|
||||||
result = await async_generate_data(hass=call.hass, **call.data)
|
result = await async_generate_text(hass=call.hass, **call.data)
|
||||||
return result.as_dict()
|
return result.as_dict() # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
class AITaskPreferences:
|
class AITaskPreferences:
|
||||||
"""AI Task preferences."""
|
"""AI Task preferences."""
|
||||||
|
|
||||||
KEYS = ("gen_data_entity_id",)
|
gen_text_entity_id: str | None = None
|
||||||
|
|
||||||
gen_data_entity_id: str | None = None
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant) -> None:
|
def __init__(self, hass: HomeAssistant) -> None:
|
||||||
"""Initialize the preferences."""
|
"""Initialize the preferences."""
|
||||||
@@ -106,18 +94,17 @@ class AITaskPreferences:
|
|||||||
data = await self._store.async_load()
|
data = await self._store.async_load()
|
||||||
if data is None:
|
if data is None:
|
||||||
return
|
return
|
||||||
for key in self.KEYS:
|
self.gen_text_entity_id = data.get("gen_text_entity_id")
|
||||||
setattr(self, key, data[key])
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_set_preferences(
|
def async_set_preferences(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
gen_data_entity_id: str | None | UndefinedType = UNDEFINED,
|
gen_text_entity_id: str | None | UndefinedType = UNDEFINED,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set the preferences."""
|
"""Set the preferences."""
|
||||||
changed = False
|
changed = False
|
||||||
for key, value in (("gen_data_entity_id", gen_data_entity_id),):
|
for key, value in (("gen_text_entity_id", gen_text_entity_id),):
|
||||||
if value is not UNDEFINED:
|
if value is not UNDEFINED:
|
||||||
if getattr(self, key) != value:
|
if getattr(self, key) != value:
|
||||||
setattr(self, key, value)
|
setattr(self, key, value)
|
||||||
@@ -126,9 +113,16 @@ class AITaskPreferences:
|
|||||||
if not changed:
|
if not changed:
|
||||||
return
|
return
|
||||||
|
|
||||||
self._store.async_delay_save(self.as_dict, 10)
|
self._store.async_delay_save(
|
||||||
|
lambda: {
|
||||||
|
"gen_text_entity_id": self.gen_text_entity_id,
|
||||||
|
},
|
||||||
|
10,
|
||||||
|
)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def as_dict(self) -> dict[str, str | None]:
|
def as_dict(self) -> dict[str, str | None]:
|
||||||
"""Get the current preferences."""
|
"""Get the current preferences."""
|
||||||
return {key: getattr(self, key) for key in self.KEYS}
|
return {
|
||||||
|
"gen_text_entity_id": self.gen_text_entity_id,
|
||||||
|
}
|
||||||
|
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from enum import IntFlag
|
from typing import TYPE_CHECKING
|
||||||
from typing import TYPE_CHECKING, Final
|
|
||||||
|
|
||||||
from homeassistant.util.hass_dict import HassKey
|
from homeassistant.util.hass_dict import HassKey
|
||||||
|
|
||||||
@@ -17,18 +16,6 @@ DOMAIN = "ai_task"
|
|||||||
DATA_COMPONENT: HassKey[EntityComponent[AITaskEntity]] = HassKey(DOMAIN)
|
DATA_COMPONENT: HassKey[EntityComponent[AITaskEntity]] = HassKey(DOMAIN)
|
||||||
DATA_PREFERENCES: HassKey[AITaskPreferences] = HassKey(f"{DOMAIN}_preferences")
|
DATA_PREFERENCES: HassKey[AITaskPreferences] = HassKey(f"{DOMAIN}_preferences")
|
||||||
|
|
||||||
SERVICE_GENERATE_DATA = "generate_data"
|
|
||||||
|
|
||||||
ATTR_INSTRUCTIONS: Final = "instructions"
|
|
||||||
ATTR_TASK_NAME: Final = "task_name"
|
|
||||||
|
|
||||||
DEFAULT_SYSTEM_PROMPT = (
|
DEFAULT_SYSTEM_PROMPT = (
|
||||||
"You are a Home Assistant expert and help users with their tasks."
|
"You are a Home Assistant expert and help users with their tasks."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class AITaskEntityFeature(IntFlag):
|
|
||||||
"""Supported features of the AI task entity."""
|
|
||||||
|
|
||||||
GENERATE_DATA = 1
|
|
||||||
"""Generate data based on instructions."""
|
|
||||||
|
@@ -4,8 +4,6 @@ from collections.abc import AsyncGenerator
|
|||||||
import contextlib
|
import contextlib
|
||||||
from typing import final
|
from typing import final
|
||||||
|
|
||||||
from propcache.api import cached_property
|
|
||||||
|
|
||||||
from homeassistant.components.conversation import (
|
from homeassistant.components.conversation import (
|
||||||
ChatLog,
|
ChatLog,
|
||||||
UserContent,
|
UserContent,
|
||||||
@@ -17,15 +15,14 @@ from homeassistant.helpers.chat_session import async_get_chat_session
|
|||||||
from homeassistant.helpers.restore_state import RestoreEntity
|
from homeassistant.helpers.restore_state import RestoreEntity
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from .const import DEFAULT_SYSTEM_PROMPT, DOMAIN, AITaskEntityFeature
|
from .const import DEFAULT_SYSTEM_PROMPT, DOMAIN
|
||||||
from .task import GenDataTask, GenDataTaskResult
|
from .task import GenTextTask, GenTextTaskResult
|
||||||
|
|
||||||
|
|
||||||
class AITaskEntity(RestoreEntity):
|
class AITaskEntity(RestoreEntity):
|
||||||
"""Entity that supports conversations."""
|
"""Entity that supports conversations."""
|
||||||
|
|
||||||
_attr_should_poll = False
|
_attr_should_poll = False
|
||||||
_attr_supported_features = AITaskEntityFeature(0)
|
|
||||||
__last_activity: str | None = None
|
__last_activity: str | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -36,11 +33,6 @@ class AITaskEntity(RestoreEntity):
|
|||||||
return None
|
return None
|
||||||
return self.__last_activity
|
return self.__last_activity
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def supported_features(self) -> AITaskEntityFeature:
|
|
||||||
"""Flag supported features."""
|
|
||||||
return self._attr_supported_features
|
|
||||||
|
|
||||||
async def async_internal_added_to_hass(self) -> None:
|
async def async_internal_added_to_hass(self) -> None:
|
||||||
"""Call when the entity is added to hass."""
|
"""Call when the entity is added to hass."""
|
||||||
await super().async_internal_added_to_hass()
|
await super().async_internal_added_to_hass()
|
||||||
@@ -56,7 +48,7 @@ class AITaskEntity(RestoreEntity):
|
|||||||
@contextlib.asynccontextmanager
|
@contextlib.asynccontextmanager
|
||||||
async def _async_get_ai_task_chat_log(
|
async def _async_get_ai_task_chat_log(
|
||||||
self,
|
self,
|
||||||
task: GenDataTask,
|
task: GenTextTask,
|
||||||
) -> AsyncGenerator[ChatLog]:
|
) -> AsyncGenerator[ChatLog]:
|
||||||
"""Context manager used to manage the ChatLog used during an AI Task."""
|
"""Context manager used to manage the ChatLog used during an AI Task."""
|
||||||
# pylint: disable-next=contextmanager-generator-missing-cleanup
|
# pylint: disable-next=contextmanager-generator-missing-cleanup
|
||||||
@@ -84,20 +76,20 @@ class AITaskEntity(RestoreEntity):
|
|||||||
yield chat_log
|
yield chat_log
|
||||||
|
|
||||||
@final
|
@final
|
||||||
async def internal_async_generate_data(
|
async def internal_async_generate_text(
|
||||||
self,
|
self,
|
||||||
task: GenDataTask,
|
task: GenTextTask,
|
||||||
) -> GenDataTaskResult:
|
) -> GenTextTaskResult:
|
||||||
"""Run a gen data task."""
|
"""Run a gen text task."""
|
||||||
self.__last_activity = dt_util.utcnow().isoformat()
|
self.__last_activity = dt_util.utcnow().isoformat()
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
async with self._async_get_ai_task_chat_log(task) as chat_log:
|
async with self._async_get_ai_task_chat_log(task) as chat_log:
|
||||||
return await self._async_generate_data(task, chat_log)
|
return await self._async_generate_text(task, chat_log)
|
||||||
|
|
||||||
async def _async_generate_data(
|
async def _async_generate_text(
|
||||||
self,
|
self,
|
||||||
task: GenDataTask,
|
task: GenTextTask,
|
||||||
chat_log: ChatLog,
|
chat_log: ChatLog,
|
||||||
) -> GenDataTaskResult:
|
) -> GenTextTaskResult:
|
||||||
"""Handle a gen data task."""
|
"""Handle a gen text task."""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
@@ -8,15 +8,43 @@ from homeassistant.components import websocket_api
|
|||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
|
||||||
from .const import DATA_PREFERENCES
|
from .const import DATA_PREFERENCES
|
||||||
|
from .task import async_generate_text
|
||||||
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_setup(hass: HomeAssistant) -> None:
|
def async_setup(hass: HomeAssistant) -> None:
|
||||||
"""Set up the HTTP API for the conversation integration."""
|
"""Set up the HTTP API for the conversation integration."""
|
||||||
|
websocket_api.async_register_command(hass, websocket_generate_text)
|
||||||
websocket_api.async_register_command(hass, websocket_get_preferences)
|
websocket_api.async_register_command(hass, websocket_get_preferences)
|
||||||
websocket_api.async_register_command(hass, websocket_set_preferences)
|
websocket_api.async_register_command(hass, websocket_set_preferences)
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required("type"): "ai_task/generate_text",
|
||||||
|
vol.Required("task_name"): str,
|
||||||
|
vol.Optional("entity_id"): str,
|
||||||
|
vol.Required("instructions"): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@websocket_api.require_admin
|
||||||
|
@websocket_api.async_response
|
||||||
|
async def websocket_generate_text(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
connection: websocket_api.ActiveConnection,
|
||||||
|
msg: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Run a generate text task."""
|
||||||
|
msg.pop("type")
|
||||||
|
msg_id = msg.pop("id")
|
||||||
|
try:
|
||||||
|
result = await async_generate_text(hass=hass, **msg)
|
||||||
|
except ValueError as err:
|
||||||
|
connection.send_error(msg_id, websocket_api.const.ERR_UNKNOWN_ERROR, str(err))
|
||||||
|
return
|
||||||
|
connection.send_result(msg_id, result.as_dict())
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.websocket_command(
|
@websocket_api.websocket_command(
|
||||||
{
|
{
|
||||||
vol.Required("type"): "ai_task/preferences/get",
|
vol.Required("type"): "ai_task/preferences/get",
|
||||||
@@ -36,7 +64,7 @@ def websocket_get_preferences(
|
|||||||
@websocket_api.websocket_command(
|
@websocket_api.websocket_command(
|
||||||
{
|
{
|
||||||
vol.Required("type"): "ai_task/preferences/set",
|
vol.Required("type"): "ai_task/preferences/set",
|
||||||
vol.Optional("gen_data_entity_id"): vol.Any(str, None),
|
vol.Optional("gen_text_entity_id"): vol.Any(str, None),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@websocket_api.require_admin
|
@websocket_api.require_admin
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"services": {
|
"services": {
|
||||||
"generate_data": {
|
"generate_text": {
|
||||||
"service": "mdi:file-star-four-points-outline"
|
"service": "mdi:file-star-four-points-outline"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
generate_data:
|
generate_text:
|
||||||
fields:
|
fields:
|
||||||
task_name:
|
task_name:
|
||||||
example: "home summary"
|
example: "home summary"
|
||||||
@@ -6,7 +6,7 @@ generate_data:
|
|||||||
selector:
|
selector:
|
||||||
text:
|
text:
|
||||||
instructions:
|
instructions:
|
||||||
example: "Generate a funny notification that the garage door was left open"
|
example: "Funny notification that garage door left open"
|
||||||
required: true
|
required: true
|
||||||
selector:
|
selector:
|
||||||
text:
|
text:
|
||||||
@@ -14,6 +14,4 @@ generate_data:
|
|||||||
required: false
|
required: false
|
||||||
selector:
|
selector:
|
||||||
entity:
|
entity:
|
||||||
domain: ai_task
|
domain: llm_task
|
||||||
supported_features:
|
|
||||||
- ai_task.AITaskEntityFeature.GENERATE_DATA
|
|
||||||
|
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"services": {
|
"services": {
|
||||||
"generate_data": {
|
"generate_text": {
|
||||||
"name": "Generate data",
|
"name": "Generate text",
|
||||||
"description": "Uses AI to run a task that generates data.",
|
"description": "Use AI to run a task that generates text.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"task_name": {
|
"task_name": {
|
||||||
"name": "Task name",
|
"name": "Task Name",
|
||||||
"description": "Name of the task."
|
"description": "Name of the task."
|
||||||
},
|
},
|
||||||
"instructions": {
|
"instructions": {
|
||||||
|
@@ -3,39 +3,32 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
|
||||||
|
|
||||||
from .const import DATA_COMPONENT, DATA_PREFERENCES, AITaskEntityFeature
|
from .const import DATA_COMPONENT, DATA_PREFERENCES
|
||||||
|
|
||||||
|
|
||||||
async def async_generate_data(
|
async def async_generate_text(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
*,
|
*,
|
||||||
task_name: str,
|
task_name: str,
|
||||||
entity_id: str | None = None,
|
entity_id: str | None = None,
|
||||||
instructions: str,
|
instructions: str,
|
||||||
) -> GenDataTaskResult:
|
) -> GenTextTaskResult:
|
||||||
"""Run a task in the AI Task integration."""
|
"""Run a task in the AI Task integration."""
|
||||||
if entity_id is None:
|
if entity_id is None:
|
||||||
entity_id = hass.data[DATA_PREFERENCES].gen_data_entity_id
|
entity_id = hass.data[DATA_PREFERENCES].gen_text_entity_id
|
||||||
|
|
||||||
if entity_id is None:
|
if entity_id is None:
|
||||||
raise HomeAssistantError("No entity_id provided and no preferred entity set")
|
raise ValueError("No entity_id provided and no preferred entity set")
|
||||||
|
|
||||||
entity = hass.data[DATA_COMPONENT].get_entity(entity_id)
|
entity = hass.data[DATA_COMPONENT].get_entity(entity_id)
|
||||||
if entity is None:
|
if entity is None:
|
||||||
raise HomeAssistantError(f"AI Task entity {entity_id} not found")
|
raise ValueError(f"AI Task entity {entity_id} not found")
|
||||||
|
|
||||||
if AITaskEntityFeature.GENERATE_DATA not in entity.supported_features:
|
return await entity.internal_async_generate_text(
|
||||||
raise HomeAssistantError(
|
GenTextTask(
|
||||||
f"AI Task entity {entity_id} does not support generating data"
|
|
||||||
)
|
|
||||||
|
|
||||||
return await entity.internal_async_generate_data(
|
|
||||||
GenDataTask(
|
|
||||||
name=task_name,
|
name=task_name,
|
||||||
instructions=instructions,
|
instructions=instructions,
|
||||||
)
|
)
|
||||||
@@ -43,8 +36,8 @@ async def async_generate_data(
|
|||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class GenDataTask:
|
class GenTextTask:
|
||||||
"""Gen data task to be processed."""
|
"""Gen text task to be processed."""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
"""Name of the task."""
|
"""Name of the task."""
|
||||||
@@ -54,22 +47,22 @@ class GenDataTask:
|
|||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
"""Return task as a string."""
|
"""Return task as a string."""
|
||||||
return f"<GenDataTask {self.name}: {id(self)}>"
|
return f"<GenTextTask {self.name}: {id(self)}>"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class GenDataTaskResult:
|
class GenTextTaskResult:
|
||||||
"""Result of gen data task."""
|
"""Result of gen text task."""
|
||||||
|
|
||||||
conversation_id: str
|
conversation_id: str
|
||||||
"""Unique identifier for the conversation."""
|
"""Unique identifier for the conversation."""
|
||||||
|
|
||||||
data: Any
|
result: str
|
||||||
"""Data generated by the task."""
|
"""Result of the task."""
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
def as_dict(self) -> dict[str, str]:
|
||||||
"""Return result as a dict."""
|
"""Return result as a dict."""
|
||||||
return {
|
return {
|
||||||
"conversation_id": self.conversation_id,
|
"conversation_id": self.conversation_id,
|
||||||
"data": self.data,
|
"result": self.result,
|
||||||
}
|
}
|
||||||
|
@@ -39,14 +39,14 @@ class AirlyFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
)
|
)
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
try:
|
try:
|
||||||
location_point_valid = await check_location(
|
location_point_valid = await test_location(
|
||||||
websession,
|
websession,
|
||||||
user_input["api_key"],
|
user_input["api_key"],
|
||||||
user_input["latitude"],
|
user_input["latitude"],
|
||||||
user_input["longitude"],
|
user_input["longitude"],
|
||||||
)
|
)
|
||||||
if not location_point_valid:
|
if not location_point_valid:
|
||||||
location_nearest_valid = await check_location(
|
location_nearest_valid = await test_location(
|
||||||
websession,
|
websession,
|
||||||
user_input["api_key"],
|
user_input["api_key"],
|
||||||
user_input["latitude"],
|
user_input["latitude"],
|
||||||
@@ -88,7 +88,7 @@ class AirlyFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def check_location(
|
async def test_location(
|
||||||
client: ClientSession,
|
client: ClientSession,
|
||||||
api_key: str,
|
api_key: str,
|
||||||
latitude: float,
|
latitude: float,
|
||||||
|
@@ -71,7 +71,7 @@ class AirNowDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
|
|
||||||
async def _async_update_data(self) -> dict[str, Any]:
|
async def _async_update_data(self) -> dict[str, Any]:
|
||||||
"""Update data via library."""
|
"""Update data via library."""
|
||||||
data: dict[str, Any] = {}
|
data = {}
|
||||||
try:
|
try:
|
||||||
obs = await self.airnow.observations.latLong(
|
obs = await self.airnow.observations.latLong(
|
||||||
self.latitude,
|
self.latitude,
|
||||||
|
@@ -6,5 +6,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/airnow",
|
"documentation": "https://www.home-assistant.io/integrations/airnow",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["pyairnow"],
|
"loggers": ["pyairnow"],
|
||||||
"requirements": ["pyairnow==1.3.1"]
|
"requirements": ["pyairnow==1.2.1"]
|
||||||
}
|
}
|
||||||
|
@@ -10,7 +10,7 @@ from aioairq.core import AirQ, identify_warming_up_sensors
|
|||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD
|
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ class AirQCoordinator(DataUpdateCoordinator):
|
|||||||
name=DOMAIN,
|
name=DOMAIN,
|
||||||
update_interval=timedelta(seconds=UPDATE_INTERVAL),
|
update_interval=timedelta(seconds=UPDATE_INTERVAL),
|
||||||
)
|
)
|
||||||
session = async_create_clientsession(hass)
|
session = async_get_clientsession(hass)
|
||||||
self.airq = AirQ(
|
self.airq = AirQ(
|
||||||
entry.data[CONF_IP_ADDRESS], entry.data[CONF_PASSWORD], session
|
entry.data[CONF_IP_ADDRESS], entry.data[CONF_PASSWORD], session
|
||||||
)
|
)
|
||||||
|
@@ -8,7 +8,6 @@ from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
|
|||||||
PLATFORMS = [
|
PLATFORMS = [
|
||||||
Platform.BINARY_SENSOR,
|
Platform.BINARY_SENSOR,
|
||||||
Platform.NOTIFY,
|
Platform.NOTIFY,
|
||||||
Platform.SENSOR,
|
|
||||||
Platform.SWITCH,
|
Platform.SWITCH,
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -29,8 +28,5 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
|
|||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
coordinator = entry.runtime_data
|
await entry.runtime_data.api.close()
|
||||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
await coordinator.api.close()
|
|
||||||
|
|
||||||
return unload_ok
|
|
||||||
|
@@ -7,7 +7,6 @@ from dataclasses import dataclass
|
|||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
from aioamazondevices.api import AmazonDevice
|
from aioamazondevices.api import AmazonDevice
|
||||||
from aioamazondevices.const import SENSOR_STATE_OFF
|
|
||||||
|
|
||||||
from homeassistant.components.binary_sensor import (
|
from homeassistant.components.binary_sensor import (
|
||||||
BinarySensorDeviceClass,
|
BinarySensorDeviceClass,
|
||||||
@@ -29,8 +28,7 @@ PARALLEL_UPDATES = 0
|
|||||||
class AmazonBinarySensorEntityDescription(BinarySensorEntityDescription):
|
class AmazonBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||||
"""Alexa Devices binary sensor entity description."""
|
"""Alexa Devices binary sensor entity description."""
|
||||||
|
|
||||||
is_on_fn: Callable[[AmazonDevice, str], bool]
|
is_on_fn: Callable[[AmazonDevice], bool]
|
||||||
is_supported: Callable[[AmazonDevice, str], bool] = lambda device, key: True
|
|
||||||
|
|
||||||
|
|
||||||
BINARY_SENSORS: Final = (
|
BINARY_SENSORS: Final = (
|
||||||
@@ -38,49 +36,13 @@ BINARY_SENSORS: Final = (
|
|||||||
key="online",
|
key="online",
|
||||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
is_on_fn=lambda device, _: device.online,
|
is_on_fn=lambda _device: _device.online,
|
||||||
),
|
),
|
||||||
AmazonBinarySensorEntityDescription(
|
AmazonBinarySensorEntityDescription(
|
||||||
key="bluetooth",
|
key="bluetooth",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
translation_key="bluetooth",
|
translation_key="bluetooth",
|
||||||
is_on_fn=lambda device, _: device.bluetooth_state,
|
is_on_fn=lambda _device: _device.bluetooth_state,
|
||||||
),
|
|
||||||
AmazonBinarySensorEntityDescription(
|
|
||||||
key="babyCryDetectionState",
|
|
||||||
translation_key="baby_cry_detection",
|
|
||||||
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
|
|
||||||
is_supported=lambda device, key: device.sensors.get(key) is not None,
|
|
||||||
),
|
|
||||||
AmazonBinarySensorEntityDescription(
|
|
||||||
key="beepingApplianceDetectionState",
|
|
||||||
translation_key="beeping_appliance_detection",
|
|
||||||
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
|
|
||||||
is_supported=lambda device, key: device.sensors.get(key) is not None,
|
|
||||||
),
|
|
||||||
AmazonBinarySensorEntityDescription(
|
|
||||||
key="coughDetectionState",
|
|
||||||
translation_key="cough_detection",
|
|
||||||
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
|
|
||||||
is_supported=lambda device, key: device.sensors.get(key) is not None,
|
|
||||||
),
|
|
||||||
AmazonBinarySensorEntityDescription(
|
|
||||||
key="dogBarkDetectionState",
|
|
||||||
translation_key="dog_bark_detection",
|
|
||||||
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
|
|
||||||
is_supported=lambda device, key: device.sensors.get(key) is not None,
|
|
||||||
),
|
|
||||||
AmazonBinarySensorEntityDescription(
|
|
||||||
key="humanPresenceDetectionState",
|
|
||||||
device_class=BinarySensorDeviceClass.MOTION,
|
|
||||||
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
|
|
||||||
is_supported=lambda device, key: device.sensors.get(key) is not None,
|
|
||||||
),
|
|
||||||
AmazonBinarySensorEntityDescription(
|
|
||||||
key="waterSoundsDetectionState",
|
|
||||||
translation_key="water_sounds_detection",
|
|
||||||
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
|
|
||||||
is_supported=lambda device, key: device.sensors.get(key) is not None,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -98,7 +60,6 @@ async def async_setup_entry(
|
|||||||
AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc)
|
AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc)
|
||||||
for sensor_desc in BINARY_SENSORS
|
for sensor_desc in BINARY_SENSORS
|
||||||
for serial_num in coordinator.data
|
for serial_num in coordinator.data
|
||||||
if sensor_desc.is_supported(coordinator.data[serial_num], sensor_desc.key)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -110,6 +71,4 @@ class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity):
|
|||||||
@property
|
@property
|
||||||
def is_on(self) -> bool:
|
def is_on(self) -> bool:
|
||||||
"""Return True if the binary sensor is on."""
|
"""Return True if the binary sensor is on."""
|
||||||
return self.entity_description.is_on_fn(
|
return self.entity_description.is_on_fn(self.device)
|
||||||
self.device, self.entity_description.key
|
|
||||||
)
|
|
||||||
|
@@ -2,39 +2,9 @@
|
|||||||
"entity": {
|
"entity": {
|
||||||
"binary_sensor": {
|
"binary_sensor": {
|
||||||
"bluetooth": {
|
"bluetooth": {
|
||||||
"default": "mdi:bluetooth-off",
|
"default": "mdi:bluetooth",
|
||||||
"state": {
|
"state": {
|
||||||
"on": "mdi:bluetooth"
|
"off": "mdi:bluetooth-off"
|
||||||
}
|
|
||||||
},
|
|
||||||
"baby_cry_detection": {
|
|
||||||
"default": "mdi:account-voice-off",
|
|
||||||
"state": {
|
|
||||||
"on": "mdi:account-voice"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"beeping_appliance_detection": {
|
|
||||||
"default": "mdi:bell-off",
|
|
||||||
"state": {
|
|
||||||
"on": "mdi:bell-ring"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"cough_detection": {
|
|
||||||
"default": "mdi:blur-off",
|
|
||||||
"state": {
|
|
||||||
"on": "mdi:blur"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"dog_bark_detection": {
|
|
||||||
"default": "mdi:dog-side-off",
|
|
||||||
"state": {
|
|
||||||
"on": "mdi:dog-side"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"water_sounds_detection": {
|
|
||||||
"default": "mdi:water-pump-off",
|
|
||||||
"state": {
|
|
||||||
"on": "mdi:water-pump"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -8,5 +8,5 @@
|
|||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["aioamazondevices"],
|
"loggers": ["aioamazondevices"],
|
||||||
"quality_scale": "bronze",
|
"quality_scale": "bronze",
|
||||||
"requirements": ["aioamazondevices==3.1.22"]
|
"requirements": ["aioamazondevices==3.1.4"]
|
||||||
}
|
}
|
||||||
|
@@ -15,7 +15,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
|||||||
|
|
||||||
from .coordinator import AmazonConfigEntry
|
from .coordinator import AmazonConfigEntry
|
||||||
from .entity import AmazonEntity
|
from .entity import AmazonEntity
|
||||||
from .utils import alexa_api_call
|
|
||||||
|
|
||||||
PARALLEL_UPDATES = 1
|
PARALLEL_UPDATES = 1
|
||||||
|
|
||||||
@@ -71,7 +70,6 @@ class AmazonNotifyEntity(AmazonEntity, NotifyEntity):
|
|||||||
|
|
||||||
entity_description: AmazonNotifyEntityDescription
|
entity_description: AmazonNotifyEntityDescription
|
||||||
|
|
||||||
@alexa_api_call
|
|
||||||
async def async_send_message(
|
async def async_send_message(
|
||||||
self, message: str, title: str | None = None, **kwargs: Any
|
self, message: str, title: str | None = None, **kwargs: Any
|
||||||
) -> None:
|
) -> None:
|
||||||
|
@@ -26,7 +26,7 @@ rules:
|
|||||||
unique-config-entry: done
|
unique-config-entry: done
|
||||||
|
|
||||||
# Silver
|
# Silver
|
||||||
action-exceptions: done
|
action-exceptions: todo
|
||||||
config-entry-unloading: done
|
config-entry-unloading: done
|
||||||
docs-configuration-parameters: todo
|
docs-configuration-parameters: todo
|
||||||
docs-installation-parameters: todo
|
docs-installation-parameters: todo
|
||||||
|
@@ -1,88 +0,0 @@
|
|||||||
"""Support for sensors."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Final
|
|
||||||
|
|
||||||
from aioamazondevices.api import AmazonDevice
|
|
||||||
|
|
||||||
from homeassistant.components.sensor import (
|
|
||||||
SensorDeviceClass,
|
|
||||||
SensorEntity,
|
|
||||||
SensorEntityDescription,
|
|
||||||
)
|
|
||||||
from homeassistant.const import LIGHT_LUX, UnitOfTemperature
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
|
||||||
from homeassistant.helpers.typing import StateType
|
|
||||||
|
|
||||||
from .coordinator import AmazonConfigEntry
|
|
||||||
from .entity import AmazonEntity
|
|
||||||
|
|
||||||
# Coordinator is used to centralize the data updates
|
|
||||||
PARALLEL_UPDATES = 0
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
|
||||||
class AmazonSensorEntityDescription(SensorEntityDescription):
|
|
||||||
"""Amazon Devices sensor entity description."""
|
|
||||||
|
|
||||||
native_unit_of_measurement_fn: Callable[[AmazonDevice, str], str] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
SENSORS: Final = (
|
|
||||||
AmazonSensorEntityDescription(
|
|
||||||
key="temperature",
|
|
||||||
device_class=SensorDeviceClass.TEMPERATURE,
|
|
||||||
native_unit_of_measurement_fn=lambda device, _key: (
|
|
||||||
UnitOfTemperature.CELSIUS
|
|
||||||
if device.sensors[_key].scale == "CELSIUS"
|
|
||||||
else UnitOfTemperature.FAHRENHEIT
|
|
||||||
),
|
|
||||||
),
|
|
||||||
AmazonSensorEntityDescription(
|
|
||||||
key="illuminance",
|
|
||||||
device_class=SensorDeviceClass.ILLUMINANCE,
|
|
||||||
native_unit_of_measurement=LIGHT_LUX,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
entry: AmazonConfigEntry,
|
|
||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
|
||||||
) -> None:
|
|
||||||
"""Set up Amazon Devices sensors based on a config entry."""
|
|
||||||
|
|
||||||
coordinator = entry.runtime_data
|
|
||||||
|
|
||||||
async_add_entities(
|
|
||||||
AmazonSensorEntity(coordinator, serial_num, sensor_desc)
|
|
||||||
for sensor_desc in SENSORS
|
|
||||||
for serial_num in coordinator.data
|
|
||||||
if coordinator.data[serial_num].sensors.get(sensor_desc.key) is not None
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AmazonSensorEntity(AmazonEntity, SensorEntity):
|
|
||||||
"""Sensor device."""
|
|
||||||
|
|
||||||
entity_description: AmazonSensorEntityDescription
|
|
||||||
|
|
||||||
@property
|
|
||||||
def native_unit_of_measurement(self) -> str | None:
|
|
||||||
"""Return the unit of measurement of the sensor."""
|
|
||||||
if self.entity_description.native_unit_of_measurement_fn:
|
|
||||||
return self.entity_description.native_unit_of_measurement_fn(
|
|
||||||
self.device, self.entity_description.key
|
|
||||||
)
|
|
||||||
|
|
||||||
return super().native_unit_of_measurement
|
|
||||||
|
|
||||||
@property
|
|
||||||
def native_value(self) -> StateType:
|
|
||||||
"""Return the state of the sensor."""
|
|
||||||
return self.device.sensors[self.entity_description.key].value
|
|
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"common": {
|
||||||
|
"data_country": "Country code",
|
||||||
"data_code": "One-time password (OTP code)",
|
"data_code": "One-time password (OTP code)",
|
||||||
"data_description_country": "The country where your Amazon account is registered.",
|
"data_description_country": "The country of your Amazon account.",
|
||||||
"data_description_username": "The email address of your Amazon account.",
|
"data_description_username": "The email address of your Amazon account.",
|
||||||
"data_description_password": "The password of your Amazon account.",
|
"data_description_password": "The password of your Amazon account.",
|
||||||
"data_description_code": "The one-time password to log in to your account. Currently, only tokens from OTP applications are supported."
|
"data_description_code": "The one-time password to log in to your account. Currently, only tokens from OTP applications are supported."
|
||||||
@@ -11,10 +12,10 @@
|
|||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"data": {
|
"data": {
|
||||||
"country": "[%key:common::config_flow::data::country%]",
|
"country": "[%key:component::alexa_devices::common::data_country%]",
|
||||||
"username": "[%key:common::config_flow::data::username%]",
|
"username": "[%key:common::config_flow::data::username%]",
|
||||||
"password": "[%key:common::config_flow::data::password%]",
|
"password": "[%key:common::config_flow::data::password%]",
|
||||||
"code": "[%key:component::alexa_devices::common::data_code%]"
|
"code": "[%key:component::alexa_devices::common::data_description_code%]"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"country": "[%key:component::alexa_devices::common::data_description_country%]",
|
"country": "[%key:component::alexa_devices::common::data_description_country%]",
|
||||||
@@ -40,21 +41,6 @@
|
|||||||
"binary_sensor": {
|
"binary_sensor": {
|
||||||
"bluetooth": {
|
"bluetooth": {
|
||||||
"name": "Bluetooth"
|
"name": "Bluetooth"
|
||||||
},
|
|
||||||
"baby_cry_detection": {
|
|
||||||
"name": "Baby crying"
|
|
||||||
},
|
|
||||||
"beeping_appliance_detection": {
|
|
||||||
"name": "Beeping appliance"
|
|
||||||
},
|
|
||||||
"cough_detection": {
|
|
||||||
"name": "Coughing"
|
|
||||||
},
|
|
||||||
"dog_bark_detection": {
|
|
||||||
"name": "Dog barking"
|
|
||||||
},
|
|
||||||
"water_sounds_detection": {
|
|
||||||
"name": "Water sounds"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"notify": {
|
"notify": {
|
||||||
@@ -70,13 +56,5 @@
|
|||||||
"name": "Do not disturb"
|
"name": "Do not disturb"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"exceptions": {
|
|
||||||
"cannot_connect": {
|
|
||||||
"message": "Error connecting: {error}"
|
|
||||||
},
|
|
||||||
"cannot_retrieve_data": {
|
|
||||||
"message": "Error retrieving data: {error}"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -14,7 +14,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
|||||||
|
|
||||||
from .coordinator import AmazonConfigEntry
|
from .coordinator import AmazonConfigEntry
|
||||||
from .entity import AmazonEntity
|
from .entity import AmazonEntity
|
||||||
from .utils import alexa_api_call
|
|
||||||
|
|
||||||
PARALLEL_UPDATES = 1
|
PARALLEL_UPDATES = 1
|
||||||
|
|
||||||
@@ -61,7 +60,6 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
|
|||||||
|
|
||||||
entity_description: AmazonSwitchEntityDescription
|
entity_description: AmazonSwitchEntityDescription
|
||||||
|
|
||||||
@alexa_api_call
|
|
||||||
async def _switch_set_state(self, state: bool) -> None:
|
async def _switch_set_state(self, state: bool) -> None:
|
||||||
"""Set desired switch state."""
|
"""Set desired switch state."""
|
||||||
method = getattr(self.coordinator.api, self.entity_description.method)
|
method = getattr(self.coordinator.api, self.entity_description.method)
|
||||||
|
@@ -1,40 +0,0 @@
|
|||||||
"""Utils for Alexa Devices."""
|
|
||||||
|
|
||||||
from collections.abc import Awaitable, Callable, Coroutine
|
|
||||||
from functools import wraps
|
|
||||||
from typing import Any, Concatenate
|
|
||||||
|
|
||||||
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
|
|
||||||
|
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
|
||||||
|
|
||||||
from .const import DOMAIN
|
|
||||||
from .entity import AmazonEntity
|
|
||||||
|
|
||||||
|
|
||||||
def alexa_api_call[_T: AmazonEntity, **_P](
|
|
||||||
func: Callable[Concatenate[_T, _P], Awaitable[None]],
|
|
||||||
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]:
|
|
||||||
"""Catch Alexa API call exceptions."""
|
|
||||||
|
|
||||||
@wraps(func)
|
|
||||||
async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None:
|
|
||||||
"""Wrap all command methods."""
|
|
||||||
try:
|
|
||||||
await func(self, *args, **kwargs)
|
|
||||||
except CannotConnect as err:
|
|
||||||
self.coordinator.last_update_success = False
|
|
||||||
raise HomeAssistantError(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="cannot_connect",
|
|
||||||
translation_placeholders={"error": repr(err)},
|
|
||||||
) from err
|
|
||||||
except CannotRetrieveData as err:
|
|
||||||
self.coordinator.last_update_success = False
|
|
||||||
raise HomeAssistantError(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="cannot_retrieve_data",
|
|
||||||
translation_placeholders={"error": repr(err)},
|
|
||||||
) from err
|
|
||||||
|
|
||||||
return cmd_wrapper
|
|
@@ -1,27 +0,0 @@
|
|||||||
"""The Altruist integration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from homeassistant.const import Platform
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
|
|
||||||
from .coordinator import AltruistConfigEntry, AltruistDataUpdateCoordinator
|
|
||||||
|
|
||||||
PLATFORMS = [Platform.SENSOR]
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: AltruistConfigEntry) -> bool:
|
|
||||||
"""Set up Altruist from a config entry."""
|
|
||||||
|
|
||||||
coordinator = AltruistDataUpdateCoordinator(hass, entry)
|
|
||||||
|
|
||||||
await coordinator.async_config_entry_first_refresh()
|
|
||||||
|
|
||||||
entry.runtime_data = coordinator
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: AltruistConfigEntry) -> bool:
|
|
||||||
"""Unload a config entry."""
|
|
||||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
|
@@ -1,107 +0,0 @@
|
|||||||
"""Config flow for the Altruist integration."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from altruistclient import AltruistClient, AltruistDeviceModel, AltruistError
|
|
||||||
import voluptuous as vol
|
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|
||||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
|
||||||
|
|
||||||
from .const import CONF_HOST, DOMAIN
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class AltruistConfigFlow(ConfigFlow, domain=DOMAIN):
|
|
||||||
"""Handle a config flow for Altruist."""
|
|
||||||
|
|
||||||
device: AltruistDeviceModel
|
|
||||||
|
|
||||||
async def async_step_user(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
|
||||||
) -> ConfigFlowResult:
|
|
||||||
"""Handle the initial step."""
|
|
||||||
errors: dict[str, str] = {}
|
|
||||||
ip_address = ""
|
|
||||||
if user_input is not None:
|
|
||||||
ip_address = user_input[CONF_HOST]
|
|
||||||
try:
|
|
||||||
client = await AltruistClient.from_ip_address(
|
|
||||||
async_get_clientsession(self.hass), ip_address
|
|
||||||
)
|
|
||||||
except AltruistError:
|
|
||||||
errors["base"] = "no_device_found"
|
|
||||||
else:
|
|
||||||
self.device = client.device
|
|
||||||
await self.async_set_unique_id(
|
|
||||||
client.device_id, raise_on_progress=False
|
|
||||||
)
|
|
||||||
self._abort_if_unique_id_configured()
|
|
||||||
return self.async_create_entry(
|
|
||||||
title=self.device.id,
|
|
||||||
data={
|
|
||||||
CONF_HOST: ip_address,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
data_schema = self.add_suggested_values_to_schema(
|
|
||||||
vol.Schema({vol.Required(CONF_HOST): str}),
|
|
||||||
{CONF_HOST: ip_address},
|
|
||||||
)
|
|
||||||
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="user",
|
|
||||||
data_schema=data_schema,
|
|
||||||
errors=errors,
|
|
||||||
description_placeholders={
|
|
||||||
"ip_address": ip_address,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_step_zeroconf(
|
|
||||||
self, discovery_info: ZeroconfServiceInfo
|
|
||||||
) -> ConfigFlowResult:
|
|
||||||
"""Handle zeroconf discovery."""
|
|
||||||
_LOGGER.debug("Zeroconf discovery: %s", discovery_info)
|
|
||||||
try:
|
|
||||||
client = await AltruistClient.from_ip_address(
|
|
||||||
async_get_clientsession(self.hass), str(discovery_info.ip_address)
|
|
||||||
)
|
|
||||||
except AltruistError:
|
|
||||||
return self.async_abort(reason="no_device_found")
|
|
||||||
|
|
||||||
self.device = client.device
|
|
||||||
_LOGGER.debug("Zeroconf device: %s", client.device)
|
|
||||||
await self.async_set_unique_id(client.device_id)
|
|
||||||
self._abort_if_unique_id_configured()
|
|
||||||
self.context.update(
|
|
||||||
{
|
|
||||||
"title_placeholders": {
|
|
||||||
"name": self.device.id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return await self.async_step_discovery_confirm()
|
|
||||||
|
|
||||||
async def async_step_discovery_confirm(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
|
||||||
) -> ConfigFlowResult:
|
|
||||||
"""Confirm discovery."""
|
|
||||||
if user_input is not None:
|
|
||||||
return self.async_create_entry(
|
|
||||||
title=self.device.id,
|
|
||||||
data={
|
|
||||||
CONF_HOST: self.device.ip_address,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
self._set_confirm_only()
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="discovery_confirm",
|
|
||||||
description_placeholders={
|
|
||||||
"model": self.device.id,
|
|
||||||
},
|
|
||||||
)
|
|
@@ -1,5 +0,0 @@
|
|||||||
"""Constants for the Altruist integration."""
|
|
||||||
|
|
||||||
DOMAIN = "altruist"
|
|
||||||
|
|
||||||
CONF_HOST = "host"
|
|
@@ -1,64 +0,0 @@
|
|||||||
"""Coordinator module for Altruist integration in Home Assistant.
|
|
||||||
|
|
||||||
This module defines the AltruistDataUpdateCoordinator class, which manages
|
|
||||||
data updates for Altruist sensors using the AltruistClient.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import timedelta
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from altruistclient import AltruistClient, AltruistError
|
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
|
||||||
|
|
||||||
from .const import CONF_HOST
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
UPDATE_INTERVAL = timedelta(seconds=15)
|
|
||||||
|
|
||||||
type AltruistConfigEntry = ConfigEntry[AltruistDataUpdateCoordinator]
|
|
||||||
|
|
||||||
|
|
||||||
class AltruistDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]):
|
|
||||||
"""Coordinates data updates for Altruist sensors."""
|
|
||||||
|
|
||||||
client: AltruistClient
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
hass: HomeAssistant,
|
|
||||||
config_entry: AltruistConfigEntry,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize the data update coordinator for Altruist sensors."""
|
|
||||||
device_id = config_entry.unique_id
|
|
||||||
super().__init__(
|
|
||||||
hass,
|
|
||||||
logger=_LOGGER,
|
|
||||||
config_entry=config_entry,
|
|
||||||
name=f"Altruist {device_id}",
|
|
||||||
update_interval=UPDATE_INTERVAL,
|
|
||||||
)
|
|
||||||
self._ip_address = config_entry.data[CONF_HOST]
|
|
||||||
|
|
||||||
async def _async_setup(self) -> None:
|
|
||||||
try:
|
|
||||||
self.client = await AltruistClient.from_ip_address(
|
|
||||||
async_get_clientsession(self.hass), self._ip_address
|
|
||||||
)
|
|
||||||
await self.client.fetch_data()
|
|
||||||
except AltruistError as e:
|
|
||||||
raise ConfigEntryNotReady("Error in Altruist setup") from e
|
|
||||||
|
|
||||||
async def _async_update_data(self) -> dict[str, str]:
|
|
||||||
try:
|
|
||||||
fetched_data = await self.client.fetch_data()
|
|
||||||
except AltruistError as ex:
|
|
||||||
raise UpdateFailed(
|
|
||||||
f"The Altruist {self.client.device_id} is unavailable: {ex}"
|
|
||||||
) from ex
|
|
||||||
return {item["value_type"]: item["value"] for item in fetched_data}
|
|
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"entity": {
|
|
||||||
"sensor": {
|
|
||||||
"pm_10": {
|
|
||||||
"default": "mdi:thought-bubble"
|
|
||||||
},
|
|
||||||
"pm_25": {
|
|
||||||
"default": "mdi:thought-bubble-outline"
|
|
||||||
},
|
|
||||||
"radiation": {
|
|
||||||
"default": "mdi:radioactive"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"domain": "altruist",
|
|
||||||
"name": "Altruist",
|
|
||||||
"codeowners": ["@airalab", "@LoSk-p"],
|
|
||||||
"config_flow": true,
|
|
||||||
"documentation": "https://www.home-assistant.io/integrations/altruist",
|
|
||||||
"integration_type": "device",
|
|
||||||
"iot_class": "local_polling",
|
|
||||||
"quality_scale": "bronze",
|
|
||||||
"requirements": ["altruistclient==0.1.1"],
|
|
||||||
"zeroconf": ["_altruist._tcp.local."]
|
|
||||||
}
|
|
@@ -1,83 +0,0 @@
|
|||||||
rules:
|
|
||||||
# Bronze
|
|
||||||
action-setup:
|
|
||||||
status: exempt
|
|
||||||
comment: |
|
|
||||||
This integration does not provide additional actions.
|
|
||||||
appropriate-polling: done
|
|
||||||
brands: done
|
|
||||||
common-modules: done
|
|
||||||
config-flow-test-coverage: done
|
|
||||||
config-flow: done
|
|
||||||
dependency-transparency: done
|
|
||||||
docs-actions:
|
|
||||||
status: exempt
|
|
||||||
comment: |
|
|
||||||
This integration does not provide additional actions.
|
|
||||||
docs-high-level-description: done
|
|
||||||
docs-installation-instructions: done
|
|
||||||
docs-removal-instructions: done
|
|
||||||
entity-event-setup:
|
|
||||||
status: exempt
|
|
||||||
comment: |
|
|
||||||
Entities of this integration does 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: done
|
|
||||||
|
|
||||||
# Silver
|
|
||||||
action-exceptions:
|
|
||||||
status: exempt
|
|
||||||
comment: |
|
|
||||||
This integration does not provide additional actions.
|
|
||||||
config-entry-unloading: done
|
|
||||||
docs-configuration-parameters:
|
|
||||||
status: exempt
|
|
||||||
comment: |
|
|
||||||
This integration does not provide options flow.
|
|
||||||
docs-installation-parameters: done
|
|
||||||
entity-unavailable: done
|
|
||||||
integration-owner: done
|
|
||||||
log-when-unavailable: done
|
|
||||||
parallel-updates: todo
|
|
||||||
reauthentication-flow: todo
|
|
||||||
test-coverage: done
|
|
||||||
|
|
||||||
# Gold
|
|
||||||
devices: done
|
|
||||||
diagnostics: todo
|
|
||||||
discovery-update-info: todo
|
|
||||||
discovery: done
|
|
||||||
docs-data-update: todo
|
|
||||||
docs-examples: todo
|
|
||||||
docs-known-limitations: todo
|
|
||||||
docs-supported-devices: todo
|
|
||||||
docs-supported-functions: todo
|
|
||||||
docs-troubleshooting: todo
|
|
||||||
docs-use-cases: todo
|
|
||||||
dynamic-devices:
|
|
||||||
status: exempt
|
|
||||||
comment: |
|
|
||||||
Device type integration
|
|
||||||
entity-category: todo
|
|
||||||
entity-device-class: done
|
|
||||||
entity-disabled-by-default: todo
|
|
||||||
entity-translations: done
|
|
||||||
exception-translations: todo
|
|
||||||
icon-translations: done
|
|
||||||
reconfiguration-flow: todo
|
|
||||||
repair-issues:
|
|
||||||
status: exempt
|
|
||||||
comment: No known use cases for repair issues or flows, yet
|
|
||||||
stale-devices:
|
|
||||||
status: exempt
|
|
||||||
comment: |
|
|
||||||
Device type integration
|
|
||||||
|
|
||||||
# Platinum
|
|
||||||
async-dependency: done
|
|
||||||
inject-websession: done
|
|
||||||
strict-typing: done
|
|
@@ -1,249 +0,0 @@
|
|||||||
"""Defines the Altruist sensor platform."""
|
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from homeassistant.components.sensor import (
|
|
||||||
SensorDeviceClass,
|
|
||||||
SensorEntity,
|
|
||||||
SensorEntityDescription,
|
|
||||||
SensorStateClass,
|
|
||||||
)
|
|
||||||
from homeassistant.const import (
|
|
||||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
|
||||||
CONCENTRATION_PARTS_PER_MILLION,
|
|
||||||
PERCENTAGE,
|
|
||||||
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
|
||||||
EntityCategory,
|
|
||||||
UnitOfPressure,
|
|
||||||
UnitOfSoundPressure,
|
|
||||||
UnitOfTemperature,
|
|
||||||
)
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
|
||||||
|
|
||||||
from . import AltruistConfigEntry
|
|
||||||
from .coordinator import AltruistDataUpdateCoordinator
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class AltruistSensorEntityDescription(SensorEntityDescription):
|
|
||||||
"""Class to describe a Sensor entity."""
|
|
||||||
|
|
||||||
native_value_fn: Callable[[str], float] = float
|
|
||||||
state_class = SensorStateClass.MEASUREMENT
|
|
||||||
|
|
||||||
|
|
||||||
SENSOR_DESCRIPTIONS = [
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.HUMIDITY,
|
|
||||||
key="BME280_humidity",
|
|
||||||
translation_key="humidity",
|
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
|
||||||
suggested_display_precision=2,
|
|
||||||
translation_placeholders={"sensor_name": "BME280"},
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.PRESSURE,
|
|
||||||
key="BME280_pressure",
|
|
||||||
translation_key="pressure",
|
|
||||||
native_unit_of_measurement=UnitOfPressure.PA,
|
|
||||||
suggested_unit_of_measurement=UnitOfPressure.MMHG,
|
|
||||||
suggested_display_precision=0,
|
|
||||||
translation_placeholders={"sensor_name": "BME280"},
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.TEMPERATURE,
|
|
||||||
key="BME280_temperature",
|
|
||||||
translation_key="temperature",
|
|
||||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
|
||||||
suggested_display_precision=2,
|
|
||||||
translation_placeholders={"sensor_name": "BME280"},
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.PRESSURE,
|
|
||||||
key="BMP_pressure",
|
|
||||||
translation_key="pressure",
|
|
||||||
native_unit_of_measurement=UnitOfPressure.PA,
|
|
||||||
suggested_unit_of_measurement=UnitOfPressure.MMHG,
|
|
||||||
suggested_display_precision=0,
|
|
||||||
translation_placeholders={"sensor_name": "BMP"},
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.TEMPERATURE,
|
|
||||||
key="BMP_temperature",
|
|
||||||
translation_key="temperature",
|
|
||||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
|
||||||
suggested_display_precision=2,
|
|
||||||
translation_placeholders={"sensor_name": "BMP"},
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.TEMPERATURE,
|
|
||||||
key="BMP280_temperature",
|
|
||||||
translation_key="temperature",
|
|
||||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
|
||||||
suggested_display_precision=2,
|
|
||||||
translation_placeholders={"sensor_name": "BMP280"},
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.PRESSURE,
|
|
||||||
key="BMP280_pressure",
|
|
||||||
translation_key="pressure",
|
|
||||||
native_unit_of_measurement=UnitOfPressure.PA,
|
|
||||||
suggested_unit_of_measurement=UnitOfPressure.MMHG,
|
|
||||||
suggested_display_precision=0,
|
|
||||||
translation_placeholders={"sensor_name": "BMP280"},
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.HUMIDITY,
|
|
||||||
key="HTU21D_humidity",
|
|
||||||
translation_key="humidity",
|
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
|
||||||
suggested_display_precision=2,
|
|
||||||
translation_placeholders={"sensor_name": "HTU21D"},
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.TEMPERATURE,
|
|
||||||
key="HTU21D_temperature",
|
|
||||||
translation_key="temperature",
|
|
||||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
|
||||||
suggested_display_precision=2,
|
|
||||||
translation_placeholders={"sensor_name": "HTU21D"},
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.PM10,
|
|
||||||
translation_key="pm_10",
|
|
||||||
key="SDS_P1",
|
|
||||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
|
||||||
suggested_display_precision=2,
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.PM25,
|
|
||||||
translation_key="pm_25",
|
|
||||||
key="SDS_P2",
|
|
||||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
|
||||||
suggested_display_precision=2,
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.HUMIDITY,
|
|
||||||
key="SHT3X_humidity",
|
|
||||||
translation_key="humidity",
|
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
|
||||||
suggested_display_precision=2,
|
|
||||||
translation_placeholders={"sensor_name": "SHT3X"},
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.TEMPERATURE,
|
|
||||||
key="SHT3X_temperature",
|
|
||||||
translation_key="temperature",
|
|
||||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
|
||||||
suggested_display_precision=2,
|
|
||||||
translation_placeholders={"sensor_name": "SHT3X"},
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
|
||||||
key="signal",
|
|
||||||
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
suggested_display_precision=0,
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.SOUND_PRESSURE,
|
|
||||||
key="PCBA_noiseMax",
|
|
||||||
translation_key="noise_max",
|
|
||||||
native_unit_of_measurement=UnitOfSoundPressure.DECIBEL,
|
|
||||||
suggested_display_precision=0,
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.SOUND_PRESSURE,
|
|
||||||
key="PCBA_noiseAvg",
|
|
||||||
translation_key="noise_avg",
|
|
||||||
native_unit_of_measurement=UnitOfSoundPressure.DECIBEL,
|
|
||||||
suggested_display_precision=0,
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.CO2,
|
|
||||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
|
||||||
translation_key="co2",
|
|
||||||
key="CCS_CO2",
|
|
||||||
suggested_display_precision=2,
|
|
||||||
translation_placeholders={"sensor_name": "CCS"},
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS,
|
|
||||||
key="CCS_TVOC",
|
|
||||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
|
||||||
suggested_display_precision=2,
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
key="GC",
|
|
||||||
native_unit_of_measurement="μR/h",
|
|
||||||
translation_key="radiation",
|
|
||||||
suggested_display_precision=2,
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.CO2,
|
|
||||||
translation_key="co2",
|
|
||||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
|
||||||
key="SCD4x_co2",
|
|
||||||
suggested_display_precision=2,
|
|
||||||
translation_placeholders={"sensor_name": "SCD4x"},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
config_entry: AltruistConfigEntry,
|
|
||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
|
||||||
) -> None:
|
|
||||||
"""Add sensors for passed config_entry in HA."""
|
|
||||||
coordinator = config_entry.runtime_data
|
|
||||||
async_add_entities(
|
|
||||||
AltruistSensor(coordinator, sensor_description)
|
|
||||||
for sensor_description in SENSOR_DESCRIPTIONS
|
|
||||||
if sensor_description.key in coordinator.data
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AltruistSensor(CoordinatorEntity[AltruistDataUpdateCoordinator], SensorEntity):
|
|
||||||
"""Implementation of a Altruist sensor."""
|
|
||||||
|
|
||||||
_attr_has_entity_name = True
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
coordinator: AltruistDataUpdateCoordinator,
|
|
||||||
description: AltruistSensorEntityDescription,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize the Altruist sensor."""
|
|
||||||
super().__init__(coordinator)
|
|
||||||
self._device = coordinator.client.device
|
|
||||||
self.entity_description: AltruistSensorEntityDescription = description
|
|
||||||
self._attr_unique_id = f"{self._device.id}-{description.key}"
|
|
||||||
self._attr_device_info = DeviceInfo(
|
|
||||||
connections={(CONNECTION_NETWORK_MAC, self._device.id)},
|
|
||||||
manufacturer="Robonomics",
|
|
||||||
model="Altruist",
|
|
||||||
sw_version=self._device.fw_version,
|
|
||||||
configuration_url=f"http://{self._device.ip_address}",
|
|
||||||
serial_number=self._device.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def available(self) -> bool:
|
|
||||||
"""Return True if entity is available."""
|
|
||||||
return (
|
|
||||||
super().available and self.entity_description.key in self.coordinator.data
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def native_value(self) -> float | int:
|
|
||||||
"""Return the native value of the sensor."""
|
|
||||||
string_value = self.coordinator.data[self.entity_description.key]
|
|
||||||
return self.entity_description.native_value_fn(string_value)
|
|
@@ -1,51 +0,0 @@
|
|||||||
{
|
|
||||||
"config": {
|
|
||||||
"flow_title": "{name}",
|
|
||||||
"step": {
|
|
||||||
"discovery_confirm": {
|
|
||||||
"description": "Do you want to start setup {model}?"
|
|
||||||
},
|
|
||||||
"user": {
|
|
||||||
"data": {
|
|
||||||
"host": "[%key:common::config_flow::data::host%]"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"host": "Altruist IP address or hostname in the local network"
|
|
||||||
},
|
|
||||||
"description": "Fill in Altruist IP address or hostname in your local network"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"abort": {
|
|
||||||
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
|
|
||||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"no_device_found": "[%key:common::config_flow::error::cannot_connect%]"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"entity": {
|
|
||||||
"sensor": {
|
|
||||||
"humidity": {
|
|
||||||
"name": "{sensor_name} humidity"
|
|
||||||
},
|
|
||||||
"pressure": {
|
|
||||||
"name": "{sensor_name} pressure"
|
|
||||||
},
|
|
||||||
"temperature": {
|
|
||||||
"name": "{sensor_name} temperature"
|
|
||||||
},
|
|
||||||
"noise_max": {
|
|
||||||
"name": "Maximum noise"
|
|
||||||
},
|
|
||||||
"noise_avg": {
|
|
||||||
"name": "Average noise"
|
|
||||||
},
|
|
||||||
"co2": {
|
|
||||||
"name": "{sensor_name} CO2"
|
|
||||||
},
|
|
||||||
"radiation": {
|
|
||||||
"name": "Radiation level"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from homeassistant.components.camera import CameraEntityFeature
|
|
||||||
from homeassistant.components.mjpeg import MjpegCamera, filter_urllib3_logging
|
from homeassistant.components.mjpeg import MjpegCamera, filter_urllib3_logging
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
CONF_HOST,
|
CONF_HOST,
|
||||||
@@ -32,7 +31,6 @@ class IPWebcamCamera(MjpegCamera):
|
|||||||
"""Representation of a IP Webcam camera."""
|
"""Representation of a IP Webcam camera."""
|
||||||
|
|
||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
_attr_supported_features = CameraEntityFeature.STREAM
|
|
||||||
|
|
||||||
def __init__(self, coordinator: AndroidIPCamDataUpdateCoordinator) -> None:
|
def __init__(self, coordinator: AndroidIPCamDataUpdateCoordinator) -> None:
|
||||||
"""Initialize the camera."""
|
"""Initialize the camera."""
|
||||||
@@ -48,17 +46,3 @@ class IPWebcamCamera(MjpegCamera):
|
|||||||
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
|
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
|
||||||
name=coordinator.config_entry.data[CONF_HOST],
|
name=coordinator.config_entry.data[CONF_HOST],
|
||||||
)
|
)
|
||||||
self._coordinator = coordinator
|
|
||||||
|
|
||||||
async def stream_source(self) -> str:
|
|
||||||
"""Get the stream source for the Android IP camera."""
|
|
||||||
return self._coordinator.cam.get_rtsp_url(
|
|
||||||
video_codec="h264", # most compatible & recommended
|
|
||||||
# while "opus" is compatible with more devices,
|
|
||||||
# HA's stream integration requires AAC or MP3,
|
|
||||||
# and IP webcam doesn't provide MP3 audio.
|
|
||||||
# aac is supported on select devices >= android 4.1.
|
|
||||||
# The stream will be quiet on devices that don't support aac,
|
|
||||||
# but it won't fail.
|
|
||||||
audio_codec="aac",
|
|
||||||
)
|
|
||||||
|
@@ -6,24 +6,13 @@ from functools import partial
|
|||||||
|
|
||||||
import anthropic
|
import anthropic
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_API_KEY, Platform
|
from homeassistant.const import CONF_API_KEY, Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
from homeassistant.helpers import (
|
from homeassistant.helpers import config_validation as cv
|
||||||
config_validation as cv,
|
|
||||||
device_registry as dr,
|
|
||||||
entity_registry as er,
|
|
||||||
)
|
|
||||||
from homeassistant.helpers.typing import ConfigType
|
|
||||||
|
|
||||||
from .const import (
|
from .const import CONF_CHAT_MODEL, DOMAIN, LOGGER, RECOMMENDED_CHAT_MODEL
|
||||||
CONF_CHAT_MODEL,
|
|
||||||
DEFAULT_CONVERSATION_NAME,
|
|
||||||
DOMAIN,
|
|
||||||
LOGGER,
|
|
||||||
RECOMMENDED_CHAT_MODEL,
|
|
||||||
)
|
|
||||||
|
|
||||||
PLATFORMS = (Platform.CONVERSATION,)
|
PLATFORMS = (Platform.CONVERSATION,)
|
||||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||||
@@ -31,24 +20,13 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
|||||||
type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient]
|
type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|
||||||
"""Set up Anthropic."""
|
|
||||||
await async_migrate_integration(hass)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> bool:
|
||||||
"""Set up Anthropic from a config entry."""
|
"""Set up Anthropic from a config entry."""
|
||||||
client = await hass.async_add_executor_job(
|
client = await hass.async_add_executor_job(
|
||||||
partial(anthropic.AsyncAnthropic, api_key=entry.data[CONF_API_KEY])
|
partial(anthropic.AsyncAnthropic, api_key=entry.data[CONF_API_KEY])
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
# Use model from first conversation subentry for validation
|
model_id = entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
|
||||||
subentries = list(entry.subentries.values())
|
|
||||||
if subentries:
|
|
||||||
model_id = subentries[0].data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
|
|
||||||
else:
|
|
||||||
model_id = RECOMMENDED_CHAT_MODEL
|
|
||||||
model = await client.models.retrieve(model_id=model_id, timeout=10.0)
|
model = await client.models.retrieve(model_id=model_id, timeout=10.0)
|
||||||
LOGGER.debug("Anthropic model: %s", model.display_name)
|
LOGGER.debug("Anthropic model: %s", model.display_name)
|
||||||
except anthropic.AuthenticationError as err:
|
except anthropic.AuthenticationError as err:
|
||||||
@@ -67,75 +45,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) ->
|
|||||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Unload Anthropic."""
|
"""Unload Anthropic."""
|
||||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
|
|
||||||
|
|
||||||
async def async_migrate_integration(hass: HomeAssistant) -> None:
|
|
||||||
"""Migrate integration entry structure."""
|
|
||||||
|
|
||||||
entries = hass.config_entries.async_entries(DOMAIN)
|
|
||||||
if not any(entry.version == 1 for entry in entries):
|
|
||||||
return
|
|
||||||
|
|
||||||
api_keys_entries: dict[str, ConfigEntry] = {}
|
|
||||||
entity_registry = er.async_get(hass)
|
|
||||||
device_registry = dr.async_get(hass)
|
|
||||||
|
|
||||||
for entry in entries:
|
|
||||||
use_existing = False
|
|
||||||
subentry = ConfigSubentry(
|
|
||||||
data=entry.options,
|
|
||||||
subentry_type="conversation",
|
|
||||||
title=entry.title,
|
|
||||||
unique_id=None,
|
|
||||||
)
|
|
||||||
if entry.data[CONF_API_KEY] not in api_keys_entries:
|
|
||||||
use_existing = True
|
|
||||||
api_keys_entries[entry.data[CONF_API_KEY]] = entry
|
|
||||||
|
|
||||||
parent_entry = api_keys_entries[entry.data[CONF_API_KEY]]
|
|
||||||
|
|
||||||
hass.config_entries.async_add_subentry(parent_entry, subentry)
|
|
||||||
conversation_entity = entity_registry.async_get_entity_id(
|
|
||||||
"conversation",
|
|
||||||
DOMAIN,
|
|
||||||
entry.entry_id,
|
|
||||||
)
|
|
||||||
if conversation_entity is not None:
|
|
||||||
entity_registry.async_update_entity(
|
|
||||||
conversation_entity,
|
|
||||||
config_entry_id=parent_entry.entry_id,
|
|
||||||
config_subentry_id=subentry.subentry_id,
|
|
||||||
new_unique_id=subentry.subentry_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
device = device_registry.async_get_device(
|
|
||||||
identifiers={(DOMAIN, entry.entry_id)}
|
|
||||||
)
|
|
||||||
if device is not None:
|
|
||||||
device_registry.async_update_device(
|
|
||||||
device.id,
|
|
||||||
new_identifiers={(DOMAIN, subentry.subentry_id)},
|
|
||||||
add_config_subentry_id=subentry.subentry_id,
|
|
||||||
add_config_entry_id=parent_entry.entry_id,
|
|
||||||
)
|
|
||||||
if parent_entry.entry_id != entry.entry_id:
|
|
||||||
device_registry.async_update_device(
|
|
||||||
device.id,
|
|
||||||
remove_config_entry_id=entry.entry_id,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
device_registry.async_update_device(
|
|
||||||
device.id,
|
|
||||||
remove_config_entry_id=entry.entry_id,
|
|
||||||
remove_config_subentry_id=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not use_existing:
|
|
||||||
await hass.config_entries.async_remove(entry.entry_id)
|
|
||||||
else:
|
|
||||||
hass.config_entries.async_update_entry(
|
|
||||||
entry,
|
|
||||||
title=DEFAULT_CONVERSATION_NAME,
|
|
||||||
options={},
|
|
||||||
version=2,
|
|
||||||
)
|
|
||||||
|
@@ -5,21 +5,20 @@ from __future__ import annotations
|
|||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from functools import partial
|
from functools import partial
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, cast
|
from types import MappingProxyType
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import anthropic
|
import anthropic
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import (
|
from homeassistant.config_entries import (
|
||||||
ConfigEntry,
|
ConfigEntry,
|
||||||
ConfigEntryState,
|
|
||||||
ConfigFlow,
|
ConfigFlow,
|
||||||
ConfigFlowResult,
|
ConfigFlowResult,
|
||||||
ConfigSubentryFlow,
|
OptionsFlow,
|
||||||
SubentryFlowResult,
|
|
||||||
)
|
)
|
||||||
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME
|
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import llm
|
from homeassistant.helpers import llm
|
||||||
from homeassistant.helpers.selector import (
|
from homeassistant.helpers.selector import (
|
||||||
NumberSelector,
|
NumberSelector,
|
||||||
@@ -37,7 +36,6 @@ from .const import (
|
|||||||
CONF_RECOMMENDED,
|
CONF_RECOMMENDED,
|
||||||
CONF_TEMPERATURE,
|
CONF_TEMPERATURE,
|
||||||
CONF_THINKING_BUDGET,
|
CONF_THINKING_BUDGET,
|
||||||
DEFAULT_CONVERSATION_NAME,
|
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
RECOMMENDED_CHAT_MODEL,
|
RECOMMENDED_CHAT_MODEL,
|
||||||
RECOMMENDED_MAX_TOKENS,
|
RECOMMENDED_MAX_TOKENS,
|
||||||
@@ -74,7 +72,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
|
|||||||
class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
|
class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
"""Handle a config flow for Anthropic."""
|
"""Handle a config flow for Anthropic."""
|
||||||
|
|
||||||
VERSION = 2
|
VERSION = 1
|
||||||
|
|
||||||
async def async_step_user(
|
async def async_step_user(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
@@ -83,7 +81,6 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
errors = {}
|
errors = {}
|
||||||
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
self._async_abort_entries_match(user_input)
|
|
||||||
try:
|
try:
|
||||||
await validate_input(self.hass, user_input)
|
await validate_input(self.hass, user_input)
|
||||||
except anthropic.APITimeoutError:
|
except anthropic.APITimeoutError:
|
||||||
@@ -105,93 +102,57 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
title="Claude",
|
title="Claude",
|
||||||
data=user_input,
|
data=user_input,
|
||||||
subentries=[
|
options=RECOMMENDED_OPTIONS,
|
||||||
{
|
|
||||||
"subentry_type": "conversation",
|
|
||||||
"data": RECOMMENDED_OPTIONS,
|
|
||||||
"title": DEFAULT_CONVERSATION_NAME,
|
|
||||||
"unique_id": None,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors or None
|
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors or None
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@staticmethod
|
||||||
@callback
|
def async_get_options_flow(
|
||||||
def async_get_supported_subentry_types(
|
config_entry: ConfigEntry,
|
||||||
cls, config_entry: ConfigEntry
|
) -> OptionsFlow:
|
||||||
) -> dict[str, type[ConfigSubentryFlow]]:
|
"""Create the options flow."""
|
||||||
"""Return subentries supported by this integration."""
|
return AnthropicOptionsFlow(config_entry)
|
||||||
return {"conversation": ConversationSubentryFlowHandler}
|
|
||||||
|
|
||||||
|
|
||||||
class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
class AnthropicOptionsFlow(OptionsFlow):
|
||||||
"""Flow for managing conversation subentries."""
|
"""Anthropic config flow options handler."""
|
||||||
|
|
||||||
last_rendered_recommended = False
|
def __init__(self, config_entry: ConfigEntry) -> None:
|
||||||
|
"""Initialize options flow."""
|
||||||
|
self.last_rendered_recommended = config_entry.options.get(
|
||||||
|
CONF_RECOMMENDED, False
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
async def async_step_init(
|
||||||
def _is_new(self) -> bool:
|
|
||||||
"""Return if this is a new subentry."""
|
|
||||||
return self.source == "user"
|
|
||||||
|
|
||||||
async def async_step_set_options(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> SubentryFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Set conversation options."""
|
"""Manage the options."""
|
||||||
# abort if entry is not loaded
|
options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options
|
||||||
if self._get_entry().state != ConfigEntryState.LOADED:
|
|
||||||
return self.async_abort(reason="entry_not_loaded")
|
|
||||||
|
|
||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
if user_input is None:
|
if user_input is not None:
|
||||||
if self._is_new:
|
if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended:
|
||||||
options = RECOMMENDED_OPTIONS.copy()
|
if not user_input.get(CONF_LLM_HASS_API):
|
||||||
|
user_input.pop(CONF_LLM_HASS_API, None)
|
||||||
|
if user_input.get(
|
||||||
|
CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET
|
||||||
|
) >= user_input.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS):
|
||||||
|
errors[CONF_THINKING_BUDGET] = "thinking_budget_too_large"
|
||||||
|
|
||||||
|
if not errors:
|
||||||
|
return self.async_create_entry(title="", data=user_input)
|
||||||
else:
|
else:
|
||||||
# If this is a reconfiguration, we need to copy the existing options
|
# Re-render the options again, now with the recommended options shown/hidden
|
||||||
# so that we can show the current values in the form.
|
self.last_rendered_recommended = user_input[CONF_RECOMMENDED]
|
||||||
options = self._get_reconfigure_subentry().data.copy()
|
|
||||||
|
|
||||||
self.last_rendered_recommended = cast(
|
options = {
|
||||||
bool, options.get(CONF_RECOMMENDED, False)
|
CONF_RECOMMENDED: user_input[CONF_RECOMMENDED],
|
||||||
)
|
CONF_PROMPT: user_input[CONF_PROMPT],
|
||||||
|
CONF_LLM_HASS_API: user_input.get(CONF_LLM_HASS_API),
|
||||||
elif user_input[CONF_RECOMMENDED] == self.last_rendered_recommended:
|
}
|
||||||
if not user_input.get(CONF_LLM_HASS_API):
|
|
||||||
user_input.pop(CONF_LLM_HASS_API, None)
|
|
||||||
if user_input.get(
|
|
||||||
CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET
|
|
||||||
) >= user_input.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS):
|
|
||||||
errors[CONF_THINKING_BUDGET] = "thinking_budget_too_large"
|
|
||||||
|
|
||||||
if not errors:
|
|
||||||
if self._is_new:
|
|
||||||
return self.async_create_entry(
|
|
||||||
title=user_input.pop(CONF_NAME),
|
|
||||||
data=user_input,
|
|
||||||
)
|
|
||||||
|
|
||||||
return self.async_update_and_abort(
|
|
||||||
self._get_entry(),
|
|
||||||
self._get_reconfigure_subentry(),
|
|
||||||
data=user_input,
|
|
||||||
)
|
|
||||||
|
|
||||||
options = user_input
|
|
||||||
self.last_rendered_recommended = user_input[CONF_RECOMMENDED]
|
|
||||||
else:
|
|
||||||
# Re-render the options again, now with the recommended options shown/hidden
|
|
||||||
self.last_rendered_recommended = user_input[CONF_RECOMMENDED]
|
|
||||||
|
|
||||||
options = {
|
|
||||||
CONF_RECOMMENDED: user_input[CONF_RECOMMENDED],
|
|
||||||
CONF_PROMPT: user_input[CONF_PROMPT],
|
|
||||||
CONF_LLM_HASS_API: user_input.get(CONF_LLM_HASS_API),
|
|
||||||
}
|
|
||||||
|
|
||||||
suggested_values = options.copy()
|
suggested_values = options.copy()
|
||||||
if not suggested_values.get(CONF_PROMPT):
|
if not suggested_values.get(CONF_PROMPT):
|
||||||
@@ -202,25 +163,19 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
|||||||
suggested_values[CONF_LLM_HASS_API] = [suggested_llm_apis]
|
suggested_values[CONF_LLM_HASS_API] = [suggested_llm_apis]
|
||||||
|
|
||||||
schema = self.add_suggested_values_to_schema(
|
schema = self.add_suggested_values_to_schema(
|
||||||
vol.Schema(
|
vol.Schema(anthropic_config_option_schema(self.hass, options)),
|
||||||
anthropic_config_option_schema(self.hass, self._is_new, options)
|
|
||||||
),
|
|
||||||
suggested_values,
|
suggested_values,
|
||||||
)
|
)
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="set_options",
|
step_id="init",
|
||||||
data_schema=schema,
|
data_schema=schema,
|
||||||
errors=errors or None,
|
errors=errors or None,
|
||||||
)
|
)
|
||||||
|
|
||||||
async_step_user = async_step_set_options
|
|
||||||
async_step_reconfigure = async_step_set_options
|
|
||||||
|
|
||||||
|
|
||||||
def anthropic_config_option_schema(
|
def anthropic_config_option_schema(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
is_new: bool,
|
|
||||||
options: Mapping[str, Any],
|
options: Mapping[str, Any],
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Return a schema for Anthropic completion options."""
|
"""Return a schema for Anthropic completion options."""
|
||||||
@@ -232,24 +187,15 @@ def anthropic_config_option_schema(
|
|||||||
for api in llm.async_get_apis(hass)
|
for api in llm.async_get_apis(hass)
|
||||||
]
|
]
|
||||||
|
|
||||||
if is_new:
|
schema = {
|
||||||
schema: dict[vol.Required | vol.Optional, Any] = {
|
vol.Optional(CONF_PROMPT): TemplateSelector(),
|
||||||
vol.Required(CONF_NAME, default=DEFAULT_CONVERSATION_NAME): str,
|
vol.Optional(
|
||||||
}
|
CONF_LLM_HASS_API,
|
||||||
else:
|
): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)),
|
||||||
schema = {}
|
vol.Required(
|
||||||
|
CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)
|
||||||
schema.update(
|
): bool,
|
||||||
{
|
}
|
||||||
vol.Optional(CONF_PROMPT): TemplateSelector(),
|
|
||||||
vol.Optional(
|
|
||||||
CONF_LLM_HASS_API,
|
|
||||||
): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)),
|
|
||||||
vol.Required(
|
|
||||||
CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)
|
|
||||||
): bool,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if options.get(CONF_RECOMMENDED):
|
if options.get(CONF_RECOMMENDED):
|
||||||
return schema
|
return schema
|
||||||
|
@@ -5,8 +5,6 @@ import logging
|
|||||||
DOMAIN = "anthropic"
|
DOMAIN = "anthropic"
|
||||||
LOGGER = logging.getLogger(__package__)
|
LOGGER = logging.getLogger(__package__)
|
||||||
|
|
||||||
DEFAULT_CONVERSATION_NAME = "Claude conversation"
|
|
||||||
|
|
||||||
CONF_RECOMMENDED = "recommended"
|
CONF_RECOMMENDED = "recommended"
|
||||||
CONF_PROMPT = "prompt"
|
CONF_PROMPT = "prompt"
|
||||||
CONF_CHAT_MODEL = "chat_model"
|
CONF_CHAT_MODEL = "chat_model"
|
||||||
|
@@ -38,7 +38,7 @@ from anthropic.types import (
|
|||||||
from voluptuous_openapi import convert
|
from voluptuous_openapi import convert
|
||||||
|
|
||||||
from homeassistant.components import conversation
|
from homeassistant.components import conversation
|
||||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
|
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
@@ -72,14 +72,8 @@ async def async_setup_entry(
|
|||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up conversation entities."""
|
"""Set up conversation entities."""
|
||||||
for subentry in config_entry.subentries.values():
|
agent = AnthropicConversationEntity(config_entry)
|
||||||
if subentry.subentry_type != "conversation":
|
async_add_entities([agent])
|
||||||
continue
|
|
||||||
|
|
||||||
async_add_entities(
|
|
||||||
[AnthropicConversationEntity(config_entry, subentry)],
|
|
||||||
config_subentry_id=subentry.subentry_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _format_tool(
|
def _format_tool(
|
||||||
@@ -332,22 +326,21 @@ class AnthropicConversationEntity(
|
|||||||
):
|
):
|
||||||
"""Anthropic conversation agent."""
|
"""Anthropic conversation agent."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
_attr_name = None
|
||||||
_attr_supports_streaming = True
|
_attr_supports_streaming = True
|
||||||
|
|
||||||
def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> None:
|
def __init__(self, entry: AnthropicConfigEntry) -> None:
|
||||||
"""Initialize the agent."""
|
"""Initialize the agent."""
|
||||||
self.entry = entry
|
self.entry = entry
|
||||||
self.subentry = subentry
|
self._attr_unique_id = entry.entry_id
|
||||||
self._attr_name = subentry.title
|
|
||||||
self._attr_unique_id = subentry.subentry_id
|
|
||||||
self._attr_device_info = dr.DeviceInfo(
|
self._attr_device_info = dr.DeviceInfo(
|
||||||
identifiers={(DOMAIN, subentry.subentry_id)},
|
identifiers={(DOMAIN, entry.entry_id)},
|
||||||
name=subentry.title,
|
|
||||||
manufacturer="Anthropic",
|
manufacturer="Anthropic",
|
||||||
model="Claude",
|
model="Claude",
|
||||||
entry_type=dr.DeviceEntryType.SERVICE,
|
entry_type=dr.DeviceEntryType.SERVICE,
|
||||||
)
|
)
|
||||||
if self.subentry.data.get(CONF_LLM_HASS_API):
|
if self.entry.options.get(CONF_LLM_HASS_API):
|
||||||
self._attr_supported_features = (
|
self._attr_supported_features = (
|
||||||
conversation.ConversationEntityFeature.CONTROL
|
conversation.ConversationEntityFeature.CONTROL
|
||||||
)
|
)
|
||||||
@@ -370,7 +363,7 @@ class AnthropicConversationEntity(
|
|||||||
chat_log: conversation.ChatLog,
|
chat_log: conversation.ChatLog,
|
||||||
) -> conversation.ConversationResult:
|
) -> conversation.ConversationResult:
|
||||||
"""Call the API."""
|
"""Call the API."""
|
||||||
options = self.subentry.data
|
options = self.entry.options
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await chat_log.async_provide_llm_data(
|
await chat_log.async_provide_llm_data(
|
||||||
@@ -400,7 +393,7 @@ class AnthropicConversationEntity(
|
|||||||
chat_log: conversation.ChatLog,
|
chat_log: conversation.ChatLog,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Generate an answer for the chat log."""
|
"""Generate an answer for the chat log."""
|
||||||
options = self.subentry.data
|
options = self.entry.options
|
||||||
|
|
||||||
tools: list[ToolParam] | None = None
|
tools: list[ToolParam] | None = None
|
||||||
if chat_log.llm_api:
|
if chat_log.llm_api:
|
||||||
|
@@ -12,44 +12,28 @@
|
|||||||
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
|
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
|
||||||
"authentication_error": "[%key:common::config_flow::error::invalid_auth%]",
|
"authentication_error": "[%key:common::config_flow::error::invalid_auth%]",
|
||||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||||
},
|
|
||||||
"abort": {
|
|
||||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"config_subentries": {
|
"options": {
|
||||||
"conversation": {
|
"step": {
|
||||||
"initiate_flow": {
|
"init": {
|
||||||
"user": "Add conversation agent",
|
"data": {
|
||||||
"reconfigure": "Reconfigure conversation agent"
|
"prompt": "Instructions",
|
||||||
},
|
"chat_model": "[%key:common::generic::model%]",
|
||||||
"entry_type": "Conversation agent",
|
"max_tokens": "Maximum tokens to return in response",
|
||||||
|
"temperature": "Temperature",
|
||||||
"step": {
|
"llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]",
|
||||||
"set_options": {
|
"recommended": "Recommended model settings",
|
||||||
"data": {
|
"thinking_budget_tokens": "Thinking budget"
|
||||||
"name": "[%key:common::config_flow::data::name%]",
|
},
|
||||||
"prompt": "Instructions",
|
"data_description": {
|
||||||
"chat_model": "[%key:common::generic::model%]",
|
"prompt": "Instruct how the LLM should respond. This can be a template.",
|
||||||
"max_tokens": "Maximum tokens to return in response",
|
"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."
|
||||||
"temperature": "Temperature",
|
|
||||||
"llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]",
|
|
||||||
"recommended": "Recommended model settings",
|
|
||||||
"thinking_budget_tokens": "Thinking budget"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"prompt": "Instruct how the LLM should respond. This can be a template.",
|
|
||||||
"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."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"abort": {
|
|
||||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
|
||||||
"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."
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"thinking_budget_too_large": "Maximum tokens must be greater than the thinking budget."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -12,7 +12,6 @@ from homeassistant.components.sensor import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
PERCENTAGE,
|
PERCENTAGE,
|
||||||
EntityCategory,
|
|
||||||
UnitOfApparentPower,
|
UnitOfApparentPower,
|
||||||
UnitOfElectricCurrent,
|
UnitOfElectricCurrent,
|
||||||
UnitOfElectricPotential,
|
UnitOfElectricPotential,
|
||||||
@@ -36,7 +35,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
|||||||
"alarmdel": SensorEntityDescription(
|
"alarmdel": SensorEntityDescription(
|
||||||
key="alarmdel",
|
key="alarmdel",
|
||||||
translation_key="alarm_delay",
|
translation_key="alarm_delay",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"ambtemp": SensorEntityDescription(
|
"ambtemp": SensorEntityDescription(
|
||||||
key="ambtemp",
|
key="ambtemp",
|
||||||
@@ -49,18 +47,15 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
|||||||
key="apc",
|
key="apc",
|
||||||
translation_key="apc_status",
|
translation_key="apc_status",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"apcmodel": SensorEntityDescription(
|
"apcmodel": SensorEntityDescription(
|
||||||
key="apcmodel",
|
key="apcmodel",
|
||||||
translation_key="apc_model",
|
translation_key="apc_model",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"badbatts": SensorEntityDescription(
|
"badbatts": SensorEntityDescription(
|
||||||
key="badbatts",
|
key="badbatts",
|
||||||
translation_key="bad_batteries",
|
translation_key="bad_batteries",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"battdate": SensorEntityDescription(
|
"battdate": SensorEntityDescription(
|
||||||
key="battdate",
|
key="battdate",
|
||||||
@@ -87,7 +82,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
|||||||
key="cable",
|
key="cable",
|
||||||
translation_key="cable_type",
|
translation_key="cable_type",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"cumonbatt": SensorEntityDescription(
|
"cumonbatt": SensorEntityDescription(
|
||||||
key="cumonbatt",
|
key="cumonbatt",
|
||||||
@@ -100,63 +94,52 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
|||||||
key="date",
|
key="date",
|
||||||
translation_key="date",
|
translation_key="date",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"dipsw": SensorEntityDescription(
|
"dipsw": SensorEntityDescription(
|
||||||
key="dipsw",
|
key="dipsw",
|
||||||
translation_key="dip_switch_settings",
|
translation_key="dip_switch_settings",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"dlowbatt": SensorEntityDescription(
|
"dlowbatt": SensorEntityDescription(
|
||||||
key="dlowbatt",
|
key="dlowbatt",
|
||||||
translation_key="low_battery_signal",
|
translation_key="low_battery_signal",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"driver": SensorEntityDescription(
|
"driver": SensorEntityDescription(
|
||||||
key="driver",
|
key="driver",
|
||||||
translation_key="driver",
|
translation_key="driver",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"dshutd": SensorEntityDescription(
|
"dshutd": SensorEntityDescription(
|
||||||
key="dshutd",
|
key="dshutd",
|
||||||
translation_key="shutdown_delay",
|
translation_key="shutdown_delay",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"dwake": SensorEntityDescription(
|
"dwake": SensorEntityDescription(
|
||||||
key="dwake",
|
key="dwake",
|
||||||
translation_key="wake_delay",
|
translation_key="wake_delay",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"end apc": SensorEntityDescription(
|
"end apc": SensorEntityDescription(
|
||||||
key="end apc",
|
key="end apc",
|
||||||
translation_key="date_and_time",
|
translation_key="date_and_time",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"extbatts": SensorEntityDescription(
|
"extbatts": SensorEntityDescription(
|
||||||
key="extbatts",
|
key="extbatts",
|
||||||
translation_key="external_batteries",
|
translation_key="external_batteries",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"firmware": SensorEntityDescription(
|
"firmware": SensorEntityDescription(
|
||||||
key="firmware",
|
key="firmware",
|
||||||
translation_key="firmware_version",
|
translation_key="firmware_version",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"hitrans": SensorEntityDescription(
|
"hitrans": SensorEntityDescription(
|
||||||
key="hitrans",
|
key="hitrans",
|
||||||
translation_key="transfer_high",
|
translation_key="transfer_high",
|
||||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||||
device_class=SensorDeviceClass.VOLTAGE,
|
device_class=SensorDeviceClass.VOLTAGE,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"hostname": SensorEntityDescription(
|
"hostname": SensorEntityDescription(
|
||||||
key="hostname",
|
key="hostname",
|
||||||
translation_key="hostname",
|
translation_key="hostname",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"humidity": SensorEntityDescription(
|
"humidity": SensorEntityDescription(
|
||||||
key="humidity",
|
key="humidity",
|
||||||
@@ -180,12 +163,10 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
|||||||
key="lastxfer",
|
key="lastxfer",
|
||||||
translation_key="last_transfer",
|
translation_key="last_transfer",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"linefail": SensorEntityDescription(
|
"linefail": SensorEntityDescription(
|
||||||
key="linefail",
|
key="linefail",
|
||||||
translation_key="line_failure",
|
translation_key="line_failure",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"linefreq": SensorEntityDescription(
|
"linefreq": SensorEntityDescription(
|
||||||
key="linefreq",
|
key="linefreq",
|
||||||
@@ -217,18 +198,15 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
|||||||
translation_key="transfer_low",
|
translation_key="transfer_low",
|
||||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||||
device_class=SensorDeviceClass.VOLTAGE,
|
device_class=SensorDeviceClass.VOLTAGE,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"mandate": SensorEntityDescription(
|
"mandate": SensorEntityDescription(
|
||||||
key="mandate",
|
key="mandate",
|
||||||
translation_key="manufacture_date",
|
translation_key="manufacture_date",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"masterupd": SensorEntityDescription(
|
"masterupd": SensorEntityDescription(
|
||||||
key="masterupd",
|
key="masterupd",
|
||||||
translation_key="master_update",
|
translation_key="master_update",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"maxlinev": SensorEntityDescription(
|
"maxlinev": SensorEntityDescription(
|
||||||
key="maxlinev",
|
key="maxlinev",
|
||||||
@@ -239,13 +217,11 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
|||||||
"maxtime": SensorEntityDescription(
|
"maxtime": SensorEntityDescription(
|
||||||
key="maxtime",
|
key="maxtime",
|
||||||
translation_key="max_time",
|
translation_key="max_time",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"mbattchg": SensorEntityDescription(
|
"mbattchg": SensorEntityDescription(
|
||||||
key="mbattchg",
|
key="mbattchg",
|
||||||
translation_key="max_battery_charge",
|
translation_key="max_battery_charge",
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"minlinev": SensorEntityDescription(
|
"minlinev": SensorEntityDescription(
|
||||||
key="minlinev",
|
key="minlinev",
|
||||||
@@ -256,48 +232,41 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
|||||||
"mintimel": SensorEntityDescription(
|
"mintimel": SensorEntityDescription(
|
||||||
key="mintimel",
|
key="mintimel",
|
||||||
translation_key="min_time",
|
translation_key="min_time",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"model": SensorEntityDescription(
|
"model": SensorEntityDescription(
|
||||||
key="model",
|
key="model",
|
||||||
translation_key="model",
|
translation_key="model",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"nombattv": SensorEntityDescription(
|
"nombattv": SensorEntityDescription(
|
||||||
key="nombattv",
|
key="nombattv",
|
||||||
translation_key="battery_nominal_voltage",
|
translation_key="battery_nominal_voltage",
|
||||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||||
device_class=SensorDeviceClass.VOLTAGE,
|
device_class=SensorDeviceClass.VOLTAGE,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"nominv": SensorEntityDescription(
|
"nominv": SensorEntityDescription(
|
||||||
key="nominv",
|
key="nominv",
|
||||||
translation_key="nominal_input_voltage",
|
translation_key="nominal_input_voltage",
|
||||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||||
device_class=SensorDeviceClass.VOLTAGE,
|
device_class=SensorDeviceClass.VOLTAGE,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"nomoutv": SensorEntityDescription(
|
"nomoutv": SensorEntityDescription(
|
||||||
key="nomoutv",
|
key="nomoutv",
|
||||||
translation_key="nominal_output_voltage",
|
translation_key="nominal_output_voltage",
|
||||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||||
device_class=SensorDeviceClass.VOLTAGE,
|
device_class=SensorDeviceClass.VOLTAGE,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"nompower": SensorEntityDescription(
|
"nompower": SensorEntityDescription(
|
||||||
key="nompower",
|
key="nompower",
|
||||||
translation_key="nominal_output_power",
|
translation_key="nominal_output_power",
|
||||||
native_unit_of_measurement=UnitOfPower.WATT,
|
native_unit_of_measurement=UnitOfPower.WATT,
|
||||||
device_class=SensorDeviceClass.POWER,
|
device_class=SensorDeviceClass.POWER,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"nomapnt": SensorEntityDescription(
|
"nomapnt": SensorEntityDescription(
|
||||||
key="nomapnt",
|
key="nomapnt",
|
||||||
translation_key="nominal_apparent_power",
|
translation_key="nominal_apparent_power",
|
||||||
native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE,
|
native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE,
|
||||||
device_class=SensorDeviceClass.APPARENT_POWER,
|
device_class=SensorDeviceClass.APPARENT_POWER,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"numxfers": SensorEntityDescription(
|
"numxfers": SensorEntityDescription(
|
||||||
key="numxfers",
|
key="numxfers",
|
||||||
@@ -322,25 +291,21 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
|||||||
key="reg1",
|
key="reg1",
|
||||||
translation_key="register_1_fault",
|
translation_key="register_1_fault",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"reg2": SensorEntityDescription(
|
"reg2": SensorEntityDescription(
|
||||||
key="reg2",
|
key="reg2",
|
||||||
translation_key="register_2_fault",
|
translation_key="register_2_fault",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"reg3": SensorEntityDescription(
|
"reg3": SensorEntityDescription(
|
||||||
key="reg3",
|
key="reg3",
|
||||||
translation_key="register_3_fault",
|
translation_key="register_3_fault",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"retpct": SensorEntityDescription(
|
"retpct": SensorEntityDescription(
|
||||||
key="retpct",
|
key="retpct",
|
||||||
translation_key="restore_capacity",
|
translation_key="restore_capacity",
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"selftest": SensorEntityDescription(
|
"selftest": SensorEntityDescription(
|
||||||
key="selftest",
|
key="selftest",
|
||||||
@@ -350,24 +315,20 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
|||||||
key="sense",
|
key="sense",
|
||||||
translation_key="sensitivity",
|
translation_key="sensitivity",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"serialno": SensorEntityDescription(
|
"serialno": SensorEntityDescription(
|
||||||
key="serialno",
|
key="serialno",
|
||||||
translation_key="serial_number",
|
translation_key="serial_number",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"starttime": SensorEntityDescription(
|
"starttime": SensorEntityDescription(
|
||||||
key="starttime",
|
key="starttime",
|
||||||
translation_key="startup_time",
|
translation_key="startup_time",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"statflag": SensorEntityDescription(
|
"statflag": SensorEntityDescription(
|
||||||
key="statflag",
|
key="statflag",
|
||||||
translation_key="online_status",
|
translation_key="online_status",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"status": SensorEntityDescription(
|
"status": SensorEntityDescription(
|
||||||
key="status",
|
key="status",
|
||||||
@@ -376,7 +337,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
|||||||
"stesti": SensorEntityDescription(
|
"stesti": SensorEntityDescription(
|
||||||
key="stesti",
|
key="stesti",
|
||||||
translation_key="self_test_interval",
|
translation_key="self_test_interval",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"timeleft": SensorEntityDescription(
|
"timeleft": SensorEntityDescription(
|
||||||
key="timeleft",
|
key="timeleft",
|
||||||
@@ -400,28 +360,23 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
|||||||
key="upsname",
|
key="upsname",
|
||||||
translation_key="ups_name",
|
translation_key="ups_name",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"version": SensorEntityDescription(
|
"version": SensorEntityDescription(
|
||||||
key="version",
|
key="version",
|
||||||
translation_key="version",
|
translation_key="version",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"xoffbat": SensorEntityDescription(
|
"xoffbat": SensorEntityDescription(
|
||||||
key="xoffbat",
|
key="xoffbat",
|
||||||
translation_key="transfer_from_battery",
|
translation_key="transfer_from_battery",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"xoffbatt": SensorEntityDescription(
|
"xoffbatt": SensorEntityDescription(
|
||||||
key="xoffbatt",
|
key="xoffbatt",
|
||||||
translation_key="transfer_from_battery",
|
translation_key="transfer_from_battery",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"xonbatt": SensorEntityDescription(
|
"xonbatt": SensorEntityDescription(
|
||||||
key="xonbatt",
|
key="xonbatt",
|
||||||
translation_key="transfer_to_battery",
|
translation_key="transfer_to_battery",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -260,18 +260,11 @@ class APIEntityStateView(HomeAssistantView):
|
|||||||
if not user.is_admin:
|
if not user.is_admin:
|
||||||
raise Unauthorized(entity_id=entity_id)
|
raise Unauthorized(entity_id=entity_id)
|
||||||
hass = request.app[KEY_HASS]
|
hass = request.app[KEY_HASS]
|
||||||
|
|
||||||
body = await request.text()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data: Any = json_loads(body) if body else None
|
data = await request.json()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return self.json_message("Invalid JSON specified.", HTTPStatus.BAD_REQUEST)
|
return self.json_message("Invalid JSON specified.", HTTPStatus.BAD_REQUEST)
|
||||||
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return self.json_message(
|
|
||||||
"State data should be a JSON object.", HTTPStatus.BAD_REQUEST
|
|
||||||
)
|
|
||||||
if (new_state := data.get("state")) is None:
|
if (new_state := data.get("state")) is None:
|
||||||
return self.json_message("No state specified.", HTTPStatus.BAD_REQUEST)
|
return self.json_message("No state specified.", HTTPStatus.BAD_REQUEST)
|
||||||
|
|
||||||
@@ -484,19 +477,9 @@ class APITemplateView(HomeAssistantView):
|
|||||||
@require_admin
|
@require_admin
|
||||||
async def post(self, request: web.Request) -> web.Response:
|
async def post(self, request: web.Request) -> web.Response:
|
||||||
"""Render a template."""
|
"""Render a template."""
|
||||||
body = await request.text()
|
|
||||||
|
|
||||||
try:
|
|
||||||
data: Any = json_loads(body) if body else None
|
|
||||||
except ValueError:
|
|
||||||
return self.json_message("Invalid JSON specified.", HTTPStatus.BAD_REQUEST)
|
|
||||||
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return self.json_message(
|
|
||||||
"Template data should be a JSON object.", HTTPStatus.BAD_REQUEST
|
|
||||||
)
|
|
||||||
tpl = _cached_template(data["template"], request.app[KEY_HASS])
|
|
||||||
try:
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
tpl = _cached_template(data["template"], request.app[KEY_HASS])
|
||||||
return tpl.async_render(variables=data.get("variables"), parse_result=False) # type: ignore[no-any-return]
|
return tpl.async_render(variables=data.get("variables"), parse_result=False) # type: ignore[no-any-return]
|
||||||
except (ValueError, TemplateError) as ex:
|
except (ValueError, TemplateError) as ex:
|
||||||
return self.json_message(
|
return self.json_message(
|
||||||
|
@@ -1119,7 +1119,6 @@ class PipelineRun:
|
|||||||
) is not None:
|
) is not None:
|
||||||
# Sentence trigger matched
|
# Sentence trigger matched
|
||||||
agent_id = "sentence_trigger"
|
agent_id = "sentence_trigger"
|
||||||
processed_locally = True
|
|
||||||
intent_response = intent.IntentResponse(
|
intent_response = intent.IntentResponse(
|
||||||
self.pipeline.conversation_language
|
self.pipeline.conversation_language
|
||||||
)
|
)
|
||||||
|
@@ -1,23 +1,13 @@
|
|||||||
"""Base class for assist satellite entities."""
|
"""Base class for assist satellite entities."""
|
||||||
|
|
||||||
from dataclasses import asdict
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from hassil.util import (
|
|
||||||
PUNCTUATION_END,
|
|
||||||
PUNCTUATION_END_WORD,
|
|
||||||
PUNCTUATION_START,
|
|
||||||
PUNCTUATION_START_WORD,
|
|
||||||
)
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components.http import StaticPathConfig
|
from homeassistant.components.http import StaticPathConfig
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import ATTR_ENTITY_ID
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
|
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.entity_component import EntityComponent
|
from homeassistant.helpers.entity_component import EntityComponent
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
@@ -33,7 +23,6 @@ from .const import (
|
|||||||
)
|
)
|
||||||
from .entity import (
|
from .entity import (
|
||||||
AssistSatelliteAnnouncement,
|
AssistSatelliteAnnouncement,
|
||||||
AssistSatelliteAnswer,
|
|
||||||
AssistSatelliteConfiguration,
|
AssistSatelliteConfiguration,
|
||||||
AssistSatelliteEntity,
|
AssistSatelliteEntity,
|
||||||
AssistSatelliteEntityDescription,
|
AssistSatelliteEntityDescription,
|
||||||
@@ -45,7 +34,6 @@ from .websocket_api import async_register_websocket_api
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"DOMAIN",
|
"DOMAIN",
|
||||||
"AssistSatelliteAnnouncement",
|
"AssistSatelliteAnnouncement",
|
||||||
"AssistSatelliteAnswer",
|
|
||||||
"AssistSatelliteConfiguration",
|
"AssistSatelliteConfiguration",
|
||||||
"AssistSatelliteEntity",
|
"AssistSatelliteEntity",
|
||||||
"AssistSatelliteEntityDescription",
|
"AssistSatelliteEntityDescription",
|
||||||
@@ -98,62 +86,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
"async_internal_start_conversation",
|
"async_internal_start_conversation",
|
||||||
[AssistSatelliteEntityFeature.START_CONVERSATION],
|
[AssistSatelliteEntityFeature.START_CONVERSATION],
|
||||||
)
|
)
|
||||||
|
|
||||||
async def handle_ask_question(call: ServiceCall) -> dict[str, Any]:
|
|
||||||
"""Handle a Show View service call."""
|
|
||||||
satellite_entity_id: str = call.data[ATTR_ENTITY_ID]
|
|
||||||
satellite_entity: AssistSatelliteEntity | None = component.get_entity(
|
|
||||||
satellite_entity_id
|
|
||||||
)
|
|
||||||
if satellite_entity is None:
|
|
||||||
raise HomeAssistantError(
|
|
||||||
f"Invalid Assist satellite entity id: {satellite_entity_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
ask_question_args = {
|
|
||||||
"question": call.data.get("question"),
|
|
||||||
"question_media_id": call.data.get("question_media_id"),
|
|
||||||
"preannounce": call.data.get("preannounce", False),
|
|
||||||
"answers": call.data.get("answers"),
|
|
||||||
}
|
|
||||||
|
|
||||||
if preannounce_media_id := call.data.get("preannounce_media_id"):
|
|
||||||
ask_question_args["preannounce_media_id"] = preannounce_media_id
|
|
||||||
|
|
||||||
answer = await satellite_entity.async_internal_ask_question(**ask_question_args)
|
|
||||||
|
|
||||||
if answer is None:
|
|
||||||
raise HomeAssistantError("No answer from satellite")
|
|
||||||
|
|
||||||
return asdict(answer)
|
|
||||||
|
|
||||||
hass.services.async_register(
|
|
||||||
domain=DOMAIN,
|
|
||||||
service="ask_question",
|
|
||||||
service_func=handle_ask_question,
|
|
||||||
schema=vol.All(
|
|
||||||
{
|
|
||||||
vol.Required(ATTR_ENTITY_ID): cv.entity_domain(DOMAIN),
|
|
||||||
vol.Optional("question"): str,
|
|
||||||
vol.Optional("question_media_id"): str,
|
|
||||||
vol.Optional("preannounce"): bool,
|
|
||||||
vol.Optional("preannounce_media_id"): str,
|
|
||||||
vol.Optional("answers"): [
|
|
||||||
{
|
|
||||||
vol.Required("id"): str,
|
|
||||||
vol.Required("sentences"): vol.All(
|
|
||||||
cv.ensure_list,
|
|
||||||
[cv.string],
|
|
||||||
has_one_non_empty_item,
|
|
||||||
has_no_punctuation,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
cv.has_at_least_one_key("question", "question_media_id"),
|
|
||||||
),
|
|
||||||
supports_response=SupportsResponse.ONLY,
|
|
||||||
)
|
|
||||||
hass.data[CONNECTION_TEST_DATA] = {}
|
hass.data[CONNECTION_TEST_DATA] = {}
|
||||||
async_register_websocket_api(hass)
|
async_register_websocket_api(hass)
|
||||||
hass.http.register_view(ConnectionTestView())
|
hass.http.register_view(ConnectionTestView())
|
||||||
@@ -178,29 +110,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
|
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
|
||||||
|
|
||||||
|
|
||||||
def has_no_punctuation(value: list[str]) -> list[str]:
|
|
||||||
"""Validate result does not contain punctuation."""
|
|
||||||
for sentence in value:
|
|
||||||
if (
|
|
||||||
PUNCTUATION_START.search(sentence)
|
|
||||||
or PUNCTUATION_END.search(sentence)
|
|
||||||
or PUNCTUATION_START_WORD.search(sentence)
|
|
||||||
or PUNCTUATION_END_WORD.search(sentence)
|
|
||||||
):
|
|
||||||
raise vol.Invalid("sentence should not contain punctuation")
|
|
||||||
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def has_one_non_empty_item(value: list[str]) -> list[str]:
|
|
||||||
"""Validate result has at least one item."""
|
|
||||||
if len(value) < 1:
|
|
||||||
raise vol.Invalid("at least one sentence is required")
|
|
||||||
|
|
||||||
for sentence in value:
|
|
||||||
if not sentence:
|
|
||||||
raise vol.Invalid("sentences cannot be empty")
|
|
||||||
|
|
||||||
return value
|
|
||||||
|
@@ -4,16 +4,12 @@ from abc import abstractmethod
|
|||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import AsyncIterable
|
from collections.abc import AsyncIterable
|
||||||
import contextlib
|
import contextlib
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import Any, Literal, final
|
from typing import Any, Literal, final
|
||||||
|
|
||||||
from hassil import Intents, recognize
|
|
||||||
from hassil.expression import Expression, ListReference, Sequence
|
|
||||||
from hassil.intents import WildcardSlotList
|
|
||||||
|
|
||||||
from homeassistant.components import conversation, media_source, stt, tts
|
from homeassistant.components import conversation, media_source, stt, tts
|
||||||
from homeassistant.components.assist_pipeline import (
|
from homeassistant.components.assist_pipeline import (
|
||||||
OPTION_PREFERRED,
|
OPTION_PREFERRED,
|
||||||
@@ -109,20 +105,6 @@ class AssistSatelliteAnnouncement:
|
|||||||
"""Media ID to be played before announcement."""
|
"""Media ID to be played before announcement."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AssistSatelliteAnswer:
|
|
||||||
"""Answer to a question."""
|
|
||||||
|
|
||||||
id: str | None
|
|
||||||
"""Matched answer id or None if no answer was matched."""
|
|
||||||
|
|
||||||
sentence: str
|
|
||||||
"""Raw sentence text from user response."""
|
|
||||||
|
|
||||||
slots: dict[str, Any] = field(default_factory=dict)
|
|
||||||
"""Matched slots from answer."""
|
|
||||||
|
|
||||||
|
|
||||||
class AssistSatelliteEntity(entity.Entity):
|
class AssistSatelliteEntity(entity.Entity):
|
||||||
"""Entity encapsulating the state and functionality of an Assist satellite."""
|
"""Entity encapsulating the state and functionality of an Assist satellite."""
|
||||||
|
|
||||||
@@ -140,7 +122,6 @@ class AssistSatelliteEntity(entity.Entity):
|
|||||||
_wake_word_intercept_future: asyncio.Future[str | None] | None = None
|
_wake_word_intercept_future: asyncio.Future[str | None] | None = None
|
||||||
_attr_tts_options: dict[str, Any] | None = None
|
_attr_tts_options: dict[str, Any] | None = None
|
||||||
_pipeline_task: asyncio.Task | None = None
|
_pipeline_task: asyncio.Task | None = None
|
||||||
_ask_question_future: asyncio.Future[str | None] | None = None
|
|
||||||
|
|
||||||
__assist_satellite_state = AssistSatelliteState.IDLE
|
__assist_satellite_state = AssistSatelliteState.IDLE
|
||||||
|
|
||||||
@@ -328,112 +309,6 @@ class AssistSatelliteEntity(entity.Entity):
|
|||||||
"""Start a conversation from the satellite."""
|
"""Start a conversation from the satellite."""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
async def async_internal_ask_question(
|
|
||||||
self,
|
|
||||||
question: str | None = None,
|
|
||||||
question_media_id: str | None = None,
|
|
||||||
preannounce: bool = True,
|
|
||||||
preannounce_media_id: str = PREANNOUNCE_URL,
|
|
||||||
answers: list[dict[str, Any]] | None = None,
|
|
||||||
) -> AssistSatelliteAnswer | None:
|
|
||||||
"""Ask a question and get a user's response from the satellite.
|
|
||||||
|
|
||||||
If question_media_id is not provided, question is synthesized to audio
|
|
||||||
with the selected pipeline.
|
|
||||||
|
|
||||||
If question_media_id is provided, it is played directly. It is possible
|
|
||||||
to omit the message and the satellite will not show any text.
|
|
||||||
|
|
||||||
If preannounce is True, a sound is played before the start message or media.
|
|
||||||
If preannounce_media_id is provided, it overrides the default sound.
|
|
||||||
|
|
||||||
Calls async_start_conversation.
|
|
||||||
"""
|
|
||||||
await self._cancel_running_pipeline()
|
|
||||||
|
|
||||||
if question is None:
|
|
||||||
question = ""
|
|
||||||
|
|
||||||
announcement = await self._resolve_announcement_media_id(
|
|
||||||
question,
|
|
||||||
question_media_id,
|
|
||||||
preannounce_media_id=preannounce_media_id if preannounce else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
if self._is_announcing:
|
|
||||||
raise SatelliteBusyError
|
|
||||||
|
|
||||||
self._is_announcing = True
|
|
||||||
self._set_state(AssistSatelliteState.RESPONDING)
|
|
||||||
self._ask_question_future = asyncio.Future()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Wait for announcement to finish
|
|
||||||
await self.async_start_conversation(announcement)
|
|
||||||
|
|
||||||
# Wait for response text
|
|
||||||
response_text = await self._ask_question_future
|
|
||||||
if response_text is None:
|
|
||||||
raise HomeAssistantError("No answer from question")
|
|
||||||
|
|
||||||
if not answers:
|
|
||||||
return AssistSatelliteAnswer(id=None, sentence=response_text)
|
|
||||||
|
|
||||||
return self._question_response_to_answer(response_text, answers)
|
|
||||||
finally:
|
|
||||||
self._is_announcing = False
|
|
||||||
self._set_state(AssistSatelliteState.IDLE)
|
|
||||||
self._ask_question_future = None
|
|
||||||
|
|
||||||
def _question_response_to_answer(
|
|
||||||
self, response_text: str, answers: list[dict[str, Any]]
|
|
||||||
) -> AssistSatelliteAnswer:
|
|
||||||
"""Match text to a pre-defined set of answers."""
|
|
||||||
|
|
||||||
# Build intents and match
|
|
||||||
intents = Intents.from_dict(
|
|
||||||
{
|
|
||||||
"language": self.hass.config.language,
|
|
||||||
"intents": {
|
|
||||||
"QuestionIntent": {
|
|
||||||
"data": [
|
|
||||||
{
|
|
||||||
"sentences": answer["sentences"],
|
|
||||||
"metadata": {"answer_id": answer["id"]},
|
|
||||||
}
|
|
||||||
for answer in answers
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Assume slot list references are wildcards
|
|
||||||
wildcard_names: set[str] = set()
|
|
||||||
for intent in intents.intents.values():
|
|
||||||
for intent_data in intent.data:
|
|
||||||
for sentence in intent_data.sentences:
|
|
||||||
_collect_list_references(sentence, wildcard_names)
|
|
||||||
|
|
||||||
for wildcard_name in wildcard_names:
|
|
||||||
intents.slot_lists[wildcard_name] = WildcardSlotList(wildcard_name)
|
|
||||||
|
|
||||||
# Match response text
|
|
||||||
result = recognize(response_text, intents)
|
|
||||||
if result is None:
|
|
||||||
# No match
|
|
||||||
return AssistSatelliteAnswer(id=None, sentence=response_text)
|
|
||||||
|
|
||||||
assert result.intent_metadata
|
|
||||||
return AssistSatelliteAnswer(
|
|
||||||
id=result.intent_metadata["answer_id"],
|
|
||||||
sentence=response_text,
|
|
||||||
slots={
|
|
||||||
entity_name: entity.value
|
|
||||||
for entity_name, entity in result.entities.items()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_accept_pipeline_from_satellite(
|
async def async_accept_pipeline_from_satellite(
|
||||||
self,
|
self,
|
||||||
audio_stream: AsyncIterable[bytes],
|
audio_stream: AsyncIterable[bytes],
|
||||||
@@ -476,11 +351,6 @@ class AssistSatelliteEntity(entity.Entity):
|
|||||||
self._internal_on_pipeline_event(PipelineEvent(PipelineEventType.RUN_END))
|
self._internal_on_pipeline_event(PipelineEvent(PipelineEventType.RUN_END))
|
||||||
return
|
return
|
||||||
|
|
||||||
if (self._ask_question_future is not None) and (
|
|
||||||
start_stage == PipelineStage.STT
|
|
||||||
):
|
|
||||||
end_stage = PipelineStage.STT
|
|
||||||
|
|
||||||
device_id = self.registry_entry.device_id if self.registry_entry else None
|
device_id = self.registry_entry.device_id if self.registry_entry else None
|
||||||
|
|
||||||
# Refresh context if necessary
|
# Refresh context if necessary
|
||||||
@@ -563,16 +433,6 @@ class AssistSatelliteEntity(entity.Entity):
|
|||||||
self._set_state(AssistSatelliteState.IDLE)
|
self._set_state(AssistSatelliteState.IDLE)
|
||||||
elif event.type is PipelineEventType.STT_START:
|
elif event.type is PipelineEventType.STT_START:
|
||||||
self._set_state(AssistSatelliteState.LISTENING)
|
self._set_state(AssistSatelliteState.LISTENING)
|
||||||
elif event.type is PipelineEventType.STT_END:
|
|
||||||
# Intercepting text for ask question
|
|
||||||
if (
|
|
||||||
(self._ask_question_future is not None)
|
|
||||||
and (not self._ask_question_future.done())
|
|
||||||
and event.data
|
|
||||||
):
|
|
||||||
self._ask_question_future.set_result(
|
|
||||||
event.data.get("stt_output", {}).get("text")
|
|
||||||
)
|
|
||||||
elif event.type is PipelineEventType.INTENT_START:
|
elif event.type is PipelineEventType.INTENT_START:
|
||||||
self._set_state(AssistSatelliteState.PROCESSING)
|
self._set_state(AssistSatelliteState.PROCESSING)
|
||||||
elif event.type is PipelineEventType.TTS_START:
|
elif event.type is PipelineEventType.TTS_START:
|
||||||
@@ -583,12 +443,6 @@ class AssistSatelliteEntity(entity.Entity):
|
|||||||
if not self._run_has_tts:
|
if not self._run_has_tts:
|
||||||
self._set_state(AssistSatelliteState.IDLE)
|
self._set_state(AssistSatelliteState.IDLE)
|
||||||
|
|
||||||
if (self._ask_question_future is not None) and (
|
|
||||||
not self._ask_question_future.done()
|
|
||||||
):
|
|
||||||
# No text for ask question
|
|
||||||
self._ask_question_future.set_result(None)
|
|
||||||
|
|
||||||
self.on_pipeline_event(event)
|
self.on_pipeline_event(event)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
@@ -723,15 +577,3 @@ class AssistSatelliteEntity(entity.Entity):
|
|||||||
media_id_source=media_id_source,
|
media_id_source=media_id_source,
|
||||||
preannounce_media_id=preannounce_media_id,
|
preannounce_media_id=preannounce_media_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _collect_list_references(expression: Expression, list_names: set[str]) -> None:
|
|
||||||
"""Collect list reference names recursively."""
|
|
||||||
if isinstance(expression, Sequence):
|
|
||||||
seq: Sequence = expression
|
|
||||||
for item in seq.items:
|
|
||||||
_collect_list_references(item, list_names)
|
|
||||||
elif isinstance(expression, ListReference):
|
|
||||||
# {list}
|
|
||||||
list_ref: ListReference = expression
|
|
||||||
list_names.add(list_ref.slot_name)
|
|
||||||
|
@@ -10,9 +10,6 @@
|
|||||||
},
|
},
|
||||||
"start_conversation": {
|
"start_conversation": {
|
||||||
"service": "mdi:forum"
|
"service": "mdi:forum"
|
||||||
},
|
|
||||||
"ask_question": {
|
|
||||||
"service": "mdi:microphone-question"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -5,6 +5,5 @@
|
|||||||
"dependencies": ["assist_pipeline", "http", "stt", "tts"],
|
"dependencies": ["assist_pipeline", "http", "stt", "tts"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/assist_satellite",
|
"documentation": "https://www.home-assistant.io/integrations/assist_satellite",
|
||||||
"integration_type": "entity",
|
"integration_type": "entity",
|
||||||
"quality_scale": "internal",
|
"quality_scale": "internal"
|
||||||
"requirements": ["hassil==2.2.3"]
|
|
||||||
}
|
}
|
||||||
|
@@ -54,49 +54,3 @@ start_conversation:
|
|||||||
required: false
|
required: false
|
||||||
selector:
|
selector:
|
||||||
text:
|
text:
|
||||||
ask_question:
|
|
||||||
fields:
|
|
||||||
entity_id:
|
|
||||||
required: true
|
|
||||||
selector:
|
|
||||||
entity:
|
|
||||||
domain: assist_satellite
|
|
||||||
supported_features:
|
|
||||||
- assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION
|
|
||||||
question:
|
|
||||||
required: false
|
|
||||||
example: "What kind of music would you like to play?"
|
|
||||||
default: ""
|
|
||||||
selector:
|
|
||||||
text:
|
|
||||||
question_media_id:
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
text:
|
|
||||||
preannounce:
|
|
||||||
required: false
|
|
||||||
default: true
|
|
||||||
selector:
|
|
||||||
boolean:
|
|
||||||
preannounce_media_id:
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
text:
|
|
||||||
answers:
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
object:
|
|
||||||
label_field: sentences
|
|
||||||
description_field: id
|
|
||||||
multiple: true
|
|
||||||
translation_key: answers
|
|
||||||
fields:
|
|
||||||
id:
|
|
||||||
required: true
|
|
||||||
selector:
|
|
||||||
text:
|
|
||||||
sentences:
|
|
||||||
required: true
|
|
||||||
selector:
|
|
||||||
text:
|
|
||||||
multiple: true
|
|
||||||
|
@@ -59,44 +59,6 @@
|
|||||||
"description": "Custom media ID to play before the start message or media."
|
"description": "Custom media ID to play before the start message or media."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"ask_question": {
|
|
||||||
"name": "Ask question",
|
|
||||||
"description": "Asks a question and gets the user's response.",
|
|
||||||
"fields": {
|
|
||||||
"entity_id": {
|
|
||||||
"name": "Entity",
|
|
||||||
"description": "Assist satellite entity to ask the question on."
|
|
||||||
},
|
|
||||||
"question": {
|
|
||||||
"name": "Question",
|
|
||||||
"description": "The question to ask."
|
|
||||||
},
|
|
||||||
"question_media_id": {
|
|
||||||
"name": "Question media ID",
|
|
||||||
"description": "The media ID of the question to use instead of text-to-speech."
|
|
||||||
},
|
|
||||||
"preannounce": {
|
|
||||||
"name": "Preannounce",
|
|
||||||
"description": "Play a sound before the start message or media."
|
|
||||||
},
|
|
||||||
"preannounce_media_id": {
|
|
||||||
"name": "Preannounce media ID",
|
|
||||||
"description": "Custom media ID to play before the start message or media."
|
|
||||||
},
|
|
||||||
"answers": {
|
|
||||||
"name": "Answers",
|
|
||||||
"description": "Possible answers to the question."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"selector": {
|
|
||||||
"answers": {
|
|
||||||
"fields": {
|
|
||||||
"id": "Answer ID",
|
|
||||||
"sentences": "Sentences"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -12,7 +12,7 @@ DATA_BLUEPRINTS = "automation_blueprints"
|
|||||||
|
|
||||||
def _blueprint_in_use(hass: HomeAssistant, blueprint_path: str) -> bool:
|
def _blueprint_in_use(hass: HomeAssistant, blueprint_path: str) -> bool:
|
||||||
"""Return True if any automation references the blueprint."""
|
"""Return True if any automation references the blueprint."""
|
||||||
from . import automations_with_blueprint # noqa: PLC0415
|
from . import automations_with_blueprint # pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
return len(automations_with_blueprint(hass, blueprint_path)) > 0
|
return len(automations_with_blueprint(hass, blueprint_path)) > 0
|
||||||
|
|
||||||
@@ -28,7 +28,8 @@ async def _reload_blueprint_automations(
|
|||||||
@callback
|
@callback
|
||||||
def async_get_blueprints(hass: HomeAssistant) -> blueprint.DomainBlueprints:
|
def async_get_blueprints(hass: HomeAssistant) -> blueprint.DomainBlueprints:
|
||||||
"""Get automation blueprints."""
|
"""Get automation blueprints."""
|
||||||
from .config import AUTOMATION_BLUEPRINT_SCHEMA # noqa: PLC0415
|
# pylint: disable-next=import-outside-toplevel
|
||||||
|
from .config import AUTOMATION_BLUEPRINT_SCHEMA
|
||||||
|
|
||||||
return blueprint.DomainBlueprints(
|
return blueprint.DomainBlueprints(
|
||||||
hass,
|
hass,
|
||||||
|
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
from homeassistant.config_entries import SOURCE_SYSTEM
|
from homeassistant.config_entries import SOURCE_SYSTEM
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, ServiceCall
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
|
||||||
from homeassistant.helpers import config_validation as cv, discovery_flow
|
from homeassistant.helpers import config_validation as cv, discovery_flow
|
||||||
|
from homeassistant.helpers.backup import DATA_BACKUP
|
||||||
from homeassistant.helpers.hassio import is_hassio
|
from homeassistant.helpers.hassio import is_hassio
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
@@ -37,6 +37,7 @@ from .manager import (
|
|||||||
IdleEvent,
|
IdleEvent,
|
||||||
IncorrectPasswordError,
|
IncorrectPasswordError,
|
||||||
ManagerBackup,
|
ManagerBackup,
|
||||||
|
ManagerStateEvent,
|
||||||
NewBackup,
|
NewBackup,
|
||||||
RestoreBackupEvent,
|
RestoreBackupEvent,
|
||||||
RestoreBackupStage,
|
RestoreBackupStage,
|
||||||
@@ -44,7 +45,6 @@ from .manager import (
|
|||||||
WrittenBackup,
|
WrittenBackup,
|
||||||
)
|
)
|
||||||
from .models import AddonInfo, AgentBackup, BackupNotFound, Folder
|
from .models import AddonInfo, AgentBackup, BackupNotFound, Folder
|
||||||
from .services import async_setup_services
|
|
||||||
from .util import suggested_filename, suggested_filename_from_name_date
|
from .util import suggested_filename, suggested_filename_from_name_date
|
||||||
from .websocket import async_register_websocket_handlers
|
from .websocket import async_register_websocket_handlers
|
||||||
|
|
||||||
@@ -71,12 +71,12 @@ __all__ = [
|
|||||||
"IncorrectPasswordError",
|
"IncorrectPasswordError",
|
||||||
"LocalBackupAgent",
|
"LocalBackupAgent",
|
||||||
"ManagerBackup",
|
"ManagerBackup",
|
||||||
|
"ManagerStateEvent",
|
||||||
"NewBackup",
|
"NewBackup",
|
||||||
"RestoreBackupEvent",
|
"RestoreBackupEvent",
|
||||||
"RestoreBackupStage",
|
"RestoreBackupStage",
|
||||||
"RestoreBackupState",
|
"RestoreBackupState",
|
||||||
"WrittenBackup",
|
"WrittenBackup",
|
||||||
"async_get_manager",
|
|
||||||
"suggested_filename",
|
"suggested_filename",
|
||||||
"suggested_filename_from_name_date",
|
"suggested_filename_from_name_date",
|
||||||
]
|
]
|
||||||
@@ -94,20 +94,46 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
if not with_hassio:
|
if not with_hassio:
|
||||||
reader_writer = CoreBackupReaderWriter(hass)
|
reader_writer = CoreBackupReaderWriter(hass)
|
||||||
else:
|
else:
|
||||||
# pylint: disable-next=hass-component-root-import
|
# pylint: disable-next=import-outside-toplevel, hass-component-root-import
|
||||||
from homeassistant.components.hassio.backup import ( # noqa: PLC0415
|
from homeassistant.components.hassio.backup import SupervisorBackupReaderWriter
|
||||||
SupervisorBackupReaderWriter,
|
|
||||||
)
|
|
||||||
|
|
||||||
reader_writer = SupervisorBackupReaderWriter(hass)
|
reader_writer = SupervisorBackupReaderWriter(hass)
|
||||||
|
|
||||||
backup_manager = BackupManager(hass, reader_writer)
|
backup_manager = BackupManager(hass, reader_writer)
|
||||||
hass.data[DATA_MANAGER] = backup_manager
|
hass.data[DATA_MANAGER] = backup_manager
|
||||||
await backup_manager.async_setup()
|
try:
|
||||||
|
await backup_manager.async_setup()
|
||||||
|
except Exception as err:
|
||||||
|
hass.data[DATA_BACKUP].manager_ready.set_exception(err)
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
hass.data[DATA_BACKUP].manager_ready.set_result(None)
|
||||||
|
|
||||||
async_register_websocket_handlers(hass, with_hassio)
|
async_register_websocket_handlers(hass, with_hassio)
|
||||||
|
|
||||||
async_setup_services(hass)
|
async def async_handle_create_service(call: ServiceCall) -> None:
|
||||||
|
"""Service handler for creating backups."""
|
||||||
|
agent_id = list(backup_manager.local_backup_agents)[0]
|
||||||
|
await backup_manager.async_create_backup(
|
||||||
|
agent_ids=[agent_id],
|
||||||
|
include_addons=None,
|
||||||
|
include_all_addons=False,
|
||||||
|
include_database=True,
|
||||||
|
include_folders=None,
|
||||||
|
include_homeassistant=True,
|
||||||
|
name=None,
|
||||||
|
password=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_handle_create_automatic_service(call: ServiceCall) -> None:
|
||||||
|
"""Service handler for creating automatic backups."""
|
||||||
|
await backup_manager.async_create_automatic_backup()
|
||||||
|
|
||||||
|
if not with_hassio:
|
||||||
|
hass.services.async_register(DOMAIN, "create", async_handle_create_service)
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN, "create_automatic", async_handle_create_automatic_service
|
||||||
|
)
|
||||||
|
|
||||||
async_register_http_views(hass)
|
async_register_http_views(hass)
|
||||||
|
|
||||||
@@ -136,15 +162,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: BackupConfigEntry) -> bo
|
|||||||
async def async_unload_entry(hass: HomeAssistant, entry: BackupConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: BackupConfigEntry) -> bool:
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def async_get_manager(hass: HomeAssistant) -> BackupManager:
|
|
||||||
"""Get the backup manager instance.
|
|
||||||
|
|
||||||
Raises HomeAssistantError if the backup integration is not available.
|
|
||||||
"""
|
|
||||||
if DATA_MANAGER not in hass.data:
|
|
||||||
raise HomeAssistantError("Backup integration is not available")
|
|
||||||
|
|
||||||
return hass.data[DATA_MANAGER]
|
|
||||||
|
38
homeassistant/components/backup/basic_websocket.py
Normal file
38
homeassistant/components/backup/basic_websocket.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""Websocket commands for the Backup integration."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.components import websocket_api
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers.backup import async_subscribe_events
|
||||||
|
|
||||||
|
from .const import DATA_MANAGER
|
||||||
|
from .manager import ManagerStateEvent
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_register_websocket_handlers(hass: HomeAssistant) -> None:
|
||||||
|
"""Register websocket commands."""
|
||||||
|
websocket_api.async_register_command(hass, handle_subscribe_events)
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.require_admin
|
||||||
|
@websocket_api.websocket_command({vol.Required("type"): "backup/subscribe_events"})
|
||||||
|
@websocket_api.async_response
|
||||||
|
async def handle_subscribe_events(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
connection: websocket_api.ActiveConnection,
|
||||||
|
msg: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Subscribe to backup events."""
|
||||||
|
|
||||||
|
def on_event(event: ManagerStateEvent) -> None:
|
||||||
|
connection.send_message(websocket_api.event_message(msg["id"], event))
|
||||||
|
|
||||||
|
if DATA_MANAGER in hass.data:
|
||||||
|
manager = hass.data[DATA_MANAGER]
|
||||||
|
on_event(manager.last_event)
|
||||||
|
connection.subscriptions[msg["id"]] = async_subscribe_events(hass, on_event)
|
||||||
|
connection.send_result(msg["id"])
|
@@ -8,6 +8,10 @@ from datetime import datetime
|
|||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers.backup import (
|
||||||
|
async_subscribe_events,
|
||||||
|
async_subscribe_platform_events,
|
||||||
|
)
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||||
|
|
||||||
from .const import DOMAIN, LOGGER
|
from .const import DOMAIN, LOGGER
|
||||||
@@ -52,8 +56,8 @@ class BackupDataUpdateCoordinator(DataUpdateCoordinator[BackupCoordinatorData]):
|
|||||||
update_interval=None,
|
update_interval=None,
|
||||||
)
|
)
|
||||||
self.unsubscribe: list[Callable[[], None]] = [
|
self.unsubscribe: list[Callable[[], None]] = [
|
||||||
backup_manager.async_subscribe_events(self._on_event),
|
async_subscribe_events(hass, self._on_event),
|
||||||
backup_manager.async_subscribe_platform_events(self._on_event),
|
async_subscribe_platform_events(hass, self._on_event),
|
||||||
]
|
]
|
||||||
|
|
||||||
self.backup_manager = backup_manager
|
self.backup_manager = backup_manager
|
||||||
|
@@ -36,6 +36,7 @@ from homeassistant.helpers import (
|
|||||||
issue_registry as ir,
|
issue_registry as ir,
|
||||||
start,
|
start,
|
||||||
)
|
)
|
||||||
|
from homeassistant.helpers.backup import DATA_BACKUP
|
||||||
from homeassistant.helpers.json import json_bytes
|
from homeassistant.helpers.json import json_bytes
|
||||||
from homeassistant.util import dt as dt_util, json as json_util
|
from homeassistant.util import dt as dt_util, json as json_util
|
||||||
|
|
||||||
@@ -371,10 +372,12 @@ class BackupManager:
|
|||||||
# Latest backup event and backup event subscribers
|
# Latest backup event and backup event subscribers
|
||||||
self.last_event: ManagerStateEvent = BlockedEvent()
|
self.last_event: ManagerStateEvent = BlockedEvent()
|
||||||
self.last_action_event: ManagerStateEvent | None = None
|
self.last_action_event: ManagerStateEvent | None = None
|
||||||
self._backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = []
|
self._backup_event_subscriptions = hass.data[
|
||||||
self._backup_platform_event_subscriptions: list[
|
DATA_BACKUP
|
||||||
Callable[[BackupPlatformEvent], None]
|
].backup_event_subscriptions
|
||||||
] = []
|
self._backup_platform_event_subscriptions = hass.data[
|
||||||
|
DATA_BACKUP
|
||||||
|
].backup_platform_event_subscriptions
|
||||||
|
|
||||||
async def async_setup(self) -> None:
|
async def async_setup(self) -> None:
|
||||||
"""Set up the backup manager."""
|
"""Set up the backup manager."""
|
||||||
@@ -1382,32 +1385,6 @@ class BackupManager:
|
|||||||
for subscription in self._backup_event_subscriptions:
|
for subscription in self._backup_event_subscriptions:
|
||||||
subscription(event)
|
subscription(event)
|
||||||
|
|
||||||
@callback
|
|
||||||
def async_subscribe_events(
|
|
||||||
self,
|
|
||||||
on_event: Callable[[ManagerStateEvent], None],
|
|
||||||
) -> Callable[[], None]:
|
|
||||||
"""Subscribe events."""
|
|
||||||
|
|
||||||
def remove_subscription() -> None:
|
|
||||||
self._backup_event_subscriptions.remove(on_event)
|
|
||||||
|
|
||||||
self._backup_event_subscriptions.append(on_event)
|
|
||||||
return remove_subscription
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def async_subscribe_platform_events(
|
|
||||||
self,
|
|
||||||
on_event: Callable[[BackupPlatformEvent], None],
|
|
||||||
) -> Callable[[], None]:
|
|
||||||
"""Subscribe to backup platform events."""
|
|
||||||
|
|
||||||
def remove_subscription() -> None:
|
|
||||||
self._backup_platform_event_subscriptions.remove(on_event)
|
|
||||||
|
|
||||||
self._backup_platform_event_subscriptions.append(on_event)
|
|
||||||
return remove_subscription
|
|
||||||
|
|
||||||
def _create_automatic_backup_failed_issue(
|
def _create_automatic_backup_failed_issue(
|
||||||
self, translation_key: str, translation_placeholders: dict[str, str] | None
|
self, translation_key: str, translation_placeholders: dict[str, str] | None
|
||||||
) -> None:
|
) -> None:
|
||||||
|
@@ -19,14 +19,9 @@ from homeassistant.components.onboarding import (
|
|||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager
|
||||||
|
|
||||||
from . import (
|
from . import BackupManager, Folder, IncorrectPasswordError, http as backup_http
|
||||||
BackupManager,
|
|
||||||
Folder,
|
|
||||||
IncorrectPasswordError,
|
|
||||||
async_get_manager,
|
|
||||||
http as backup_http,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from homeassistant.components.onboarding import OnboardingStoreData
|
from homeassistant.components.onboarding import OnboardingStoreData
|
||||||
@@ -59,7 +54,7 @@ def with_backup_manager[_ViewT: BaseOnboardingView, **_P](
|
|||||||
if self._data["done"]:
|
if self._data["done"]:
|
||||||
raise HTTPUnauthorized
|
raise HTTPUnauthorized
|
||||||
|
|
||||||
manager = async_get_manager(request.app[KEY_HASS])
|
manager = await async_get_backup_manager(request.app[KEY_HASS])
|
||||||
return await func(self, manager, request, *args, **kwargs)
|
return await func(self, manager, request, *args, **kwargs)
|
||||||
|
|
||||||
return with_backup
|
return with_backup
|
||||||
|
@@ -1,36 +0,0 @@
|
|||||||
"""The Backup integration."""
|
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant, ServiceCall
|
|
||||||
from homeassistant.helpers.hassio import is_hassio
|
|
||||||
|
|
||||||
from .const import DATA_MANAGER, DOMAIN
|
|
||||||
|
|
||||||
|
|
||||||
async def _async_handle_create_service(call: ServiceCall) -> None:
|
|
||||||
"""Service handler for creating backups."""
|
|
||||||
backup_manager = call.hass.data[DATA_MANAGER]
|
|
||||||
agent_id = list(backup_manager.local_backup_agents)[0]
|
|
||||||
await backup_manager.async_create_backup(
|
|
||||||
agent_ids=[agent_id],
|
|
||||||
include_addons=None,
|
|
||||||
include_all_addons=False,
|
|
||||||
include_database=True,
|
|
||||||
include_folders=None,
|
|
||||||
include_homeassistant=True,
|
|
||||||
name=None,
|
|
||||||
password=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _async_handle_create_automatic_service(call: ServiceCall) -> None:
|
|
||||||
"""Service handler for creating automatic backups."""
|
|
||||||
await call.hass.data[DATA_MANAGER].async_create_automatic_backup()
|
|
||||||
|
|
||||||
|
|
||||||
def async_setup_services(hass: HomeAssistant) -> None:
|
|
||||||
"""Register services."""
|
|
||||||
if not is_hassio(hass):
|
|
||||||
hass.services.async_register(DOMAIN, "create", _async_handle_create_service)
|
|
||||||
hass.services.async_register(
|
|
||||||
DOMAIN, "create_automatic", _async_handle_create_automatic_service
|
|
||||||
)
|
|
@@ -10,11 +10,7 @@ from homeassistant.helpers import config_validation as cv
|
|||||||
|
|
||||||
from .config import Day, ScheduleRecurrence
|
from .config import Day, ScheduleRecurrence
|
||||||
from .const import DATA_MANAGER, LOGGER
|
from .const import DATA_MANAGER, LOGGER
|
||||||
from .manager import (
|
from .manager import DecryptOnDowloadNotSupported, IncorrectPasswordError
|
||||||
DecryptOnDowloadNotSupported,
|
|
||||||
IncorrectPasswordError,
|
|
||||||
ManagerStateEvent,
|
|
||||||
)
|
|
||||||
from .models import BackupNotFound, Folder
|
from .models import BackupNotFound, Folder
|
||||||
|
|
||||||
|
|
||||||
@@ -34,7 +30,6 @@ def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) ->
|
|||||||
websocket_api.async_register_command(hass, handle_create_with_automatic_settings)
|
websocket_api.async_register_command(hass, handle_create_with_automatic_settings)
|
||||||
websocket_api.async_register_command(hass, handle_delete)
|
websocket_api.async_register_command(hass, handle_delete)
|
||||||
websocket_api.async_register_command(hass, handle_restore)
|
websocket_api.async_register_command(hass, handle_restore)
|
||||||
websocket_api.async_register_command(hass, handle_subscribe_events)
|
|
||||||
|
|
||||||
websocket_api.async_register_command(hass, handle_config_info)
|
websocket_api.async_register_command(hass, handle_config_info)
|
||||||
websocket_api.async_register_command(hass, handle_config_update)
|
websocket_api.async_register_command(hass, handle_config_update)
|
||||||
@@ -422,22 +417,3 @@ def handle_config_update(
|
|||||||
changes.pop("type")
|
changes.pop("type")
|
||||||
manager.config.update(**changes)
|
manager.config.update(**changes)
|
||||||
connection.send_result(msg["id"])
|
connection.send_result(msg["id"])
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.require_admin
|
|
||||||
@websocket_api.websocket_command({vol.Required("type"): "backup/subscribe_events"})
|
|
||||||
@websocket_api.async_response
|
|
||||||
async def handle_subscribe_events(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
connection: websocket_api.ActiveConnection,
|
|
||||||
msg: dict[str, Any],
|
|
||||||
) -> None:
|
|
||||||
"""Subscribe to backup events."""
|
|
||||||
|
|
||||||
def on_event(event: ManagerStateEvent) -> None:
|
|
||||||
connection.send_message(websocket_api.event_message(msg["id"], event))
|
|
||||||
|
|
||||||
manager = hass.data[DATA_MANAGER]
|
|
||||||
on_event(manager.last_event)
|
|
||||||
connection.subscriptions[msg["id"]] = manager.async_subscribe_events(on_event)
|
|
||||||
connection.send_result(msg["id"])
|
|
||||||
|
@@ -11,6 +11,6 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/bosch_alarm",
|
"documentation": "https://www.home-assistant.io/integrations/bosch_alarm",
|
||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"quality_scale": "platinum",
|
"quality_scale": "bronze",
|
||||||
"requirements": ["bosch-alarm-mode2==0.4.6"]
|
"requirements": ["bosch-alarm-mode2==0.4.6"]
|
||||||
}
|
}
|
||||||
|
@@ -28,41 +28,38 @@ rules:
|
|||||||
# Silver
|
# Silver
|
||||||
action-exceptions: done
|
action-exceptions: done
|
||||||
config-entry-unloading: done
|
config-entry-unloading: done
|
||||||
docs-configuration-parameters:
|
docs-configuration-parameters: todo
|
||||||
status: exempt
|
docs-installation-parameters: todo
|
||||||
comment: |
|
entity-unavailable: todo
|
||||||
No options flow is provided.
|
|
||||||
docs-installation-parameters: done
|
|
||||||
entity-unavailable: done
|
|
||||||
integration-owner: done
|
integration-owner: done
|
||||||
log-when-unavailable: done
|
log-when-unavailable: todo
|
||||||
parallel-updates: done
|
parallel-updates: done
|
||||||
reauthentication-flow: done
|
reauthentication-flow: done
|
||||||
test-coverage: done
|
test-coverage: done
|
||||||
|
|
||||||
# Gold
|
# Gold
|
||||||
devices: done
|
devices: done
|
||||||
diagnostics: done
|
diagnostics: todo
|
||||||
discovery-update-info: done
|
discovery-update-info: done
|
||||||
discovery: done
|
discovery: done
|
||||||
docs-data-update: done
|
docs-data-update: todo
|
||||||
docs-examples: done
|
docs-examples: todo
|
||||||
docs-known-limitations: done
|
docs-known-limitations: todo
|
||||||
docs-supported-devices: done
|
docs-supported-devices: todo
|
||||||
docs-supported-functions: done
|
docs-supported-functions: todo
|
||||||
docs-troubleshooting: done
|
docs-troubleshooting: todo
|
||||||
docs-use-cases: done
|
docs-use-cases: todo
|
||||||
dynamic-devices:
|
dynamic-devices:
|
||||||
status: exempt
|
status: exempt
|
||||||
comment: |
|
comment: |
|
||||||
Device type integration
|
Device type integration
|
||||||
entity-category: done
|
entity-category: todo
|
||||||
entity-device-class: done
|
entity-device-class: todo
|
||||||
entity-disabled-by-default: done
|
entity-disabled-by-default: todo
|
||||||
entity-translations: done
|
entity-translations: done
|
||||||
exception-translations: done
|
exception-translations: todo
|
||||||
icon-translations: done
|
icon-translations: done
|
||||||
reconfiguration-flow: done
|
reconfiguration-flow: todo
|
||||||
repair-issues:
|
repair-issues:
|
||||||
status: exempt
|
status: exempt
|
||||||
comment: |
|
comment: |
|
||||||
|
@@ -20,5 +20,5 @@
|
|||||||
"dependencies": ["bluetooth_adapters"],
|
"dependencies": ["bluetooth_adapters"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/bthome",
|
"documentation": "https://www.home-assistant.io/integrations/bthome",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"requirements": ["bthome-ble==3.13.1"]
|
"requirements": ["bthome-ble==3.12.4"]
|
||||||
}
|
}
|
||||||
|
@@ -168,6 +168,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
|
|||||||
key="windazimuth",
|
key="windazimuth",
|
||||||
translation_key="windazimuth",
|
translation_key="windazimuth",
|
||||||
native_unit_of_measurement=DEGREE,
|
native_unit_of_measurement=DEGREE,
|
||||||
|
icon="mdi:compass-outline",
|
||||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||||
state_class=SensorStateClass.MEASUREMENT_ANGLE,
|
state_class=SensorStateClass.MEASUREMENT_ANGLE,
|
||||||
),
|
),
|
||||||
|
@@ -240,10 +240,6 @@ async def _async_get_stream_image(
|
|||||||
height: int | None = None,
|
height: int | None = None,
|
||||||
wait_for_next_keyframe: bool = False,
|
wait_for_next_keyframe: bool = False,
|
||||||
) -> bytes | None:
|
) -> bytes | None:
|
||||||
if (provider := camera._webrtc_provider) and ( # noqa: SLF001
|
|
||||||
image := await provider.async_get_image(camera, width=width, height=height)
|
|
||||||
) is not None:
|
|
||||||
return image
|
|
||||||
if not camera.stream and CameraEntityFeature.STREAM in camera.supported_features:
|
if not camera.stream and CameraEntityFeature.STREAM in camera.supported_features:
|
||||||
camera.stream = await camera.async_create_stream()
|
camera.stream = await camera.async_create_stream()
|
||||||
if camera.stream:
|
if camera.stream:
|
||||||
@@ -498,6 +494,19 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
|||||||
"""Flag supported features."""
|
"""Flag supported features."""
|
||||||
return self._attr_supported_features
|
return self._attr_supported_features
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supported_features_compat(self) -> CameraEntityFeature:
|
||||||
|
"""Return the supported features as CameraEntityFeature.
|
||||||
|
|
||||||
|
Remove this compatibility shim in 2025.1 or later.
|
||||||
|
"""
|
||||||
|
features = self.supported_features
|
||||||
|
if type(features) is int:
|
||||||
|
new_features = CameraEntityFeature(features)
|
||||||
|
self._report_deprecated_supported_features_values(new_features)
|
||||||
|
return new_features
|
||||||
|
return features
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def is_recording(self) -> bool:
|
def is_recording(self) -> bool:
|
||||||
"""Return true if the device is recording."""
|
"""Return true if the device is recording."""
|
||||||
@@ -691,7 +700,9 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
|||||||
async def async_internal_added_to_hass(self) -> None:
|
async def async_internal_added_to_hass(self) -> None:
|
||||||
"""Run when entity about to be added to hass."""
|
"""Run when entity about to be added to hass."""
|
||||||
await super().async_internal_added_to_hass()
|
await super().async_internal_added_to_hass()
|
||||||
self.__supports_stream = self.supported_features & CameraEntityFeature.STREAM
|
self.__supports_stream = (
|
||||||
|
self.supported_features_compat & CameraEntityFeature.STREAM
|
||||||
|
)
|
||||||
await self.async_refresh_providers(write_state=False)
|
await self.async_refresh_providers(write_state=False)
|
||||||
|
|
||||||
async def async_refresh_providers(self, *, write_state: bool = True) -> None:
|
async def async_refresh_providers(self, *, write_state: bool = True) -> None:
|
||||||
@@ -720,7 +731,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
|||||||
self, fn: Callable[[HomeAssistant, Camera], Coroutine[None, None, _T | None]]
|
self, fn: Callable[[HomeAssistant, Camera], Coroutine[None, None, _T | None]]
|
||||||
) -> _T | None:
|
) -> _T | None:
|
||||||
"""Get first provider that supports this camera."""
|
"""Get first provider that supports this camera."""
|
||||||
if CameraEntityFeature.STREAM not in self.supported_features:
|
if CameraEntityFeature.STREAM not in self.supported_features_compat:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return await fn(self.hass, self)
|
return await fn(self.hass, self)
|
||||||
@@ -770,7 +781,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
|||||||
def camera_capabilities(self) -> CameraCapabilities:
|
def camera_capabilities(self) -> CameraCapabilities:
|
||||||
"""Return the camera capabilities."""
|
"""Return the camera capabilities."""
|
||||||
frontend_stream_types = set()
|
frontend_stream_types = set()
|
||||||
if CameraEntityFeature.STREAM in self.supported_features:
|
if CameraEntityFeature.STREAM in self.supported_features_compat:
|
||||||
if self._supports_native_async_webrtc:
|
if self._supports_native_async_webrtc:
|
||||||
# The camera has a native WebRTC implementation
|
# The camera has a native WebRTC implementation
|
||||||
frontend_stream_types.add(StreamType.WEB_RTC)
|
frontend_stream_types.add(StreamType.WEB_RTC)
|
||||||
@@ -790,7 +801,8 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
|||||||
"""
|
"""
|
||||||
super().async_write_ha_state()
|
super().async_write_ha_state()
|
||||||
if self.__supports_stream != (
|
if self.__supports_stream != (
|
||||||
supports_stream := self.supported_features & CameraEntityFeature.STREAM
|
supports_stream := self.supported_features_compat
|
||||||
|
& CameraEntityFeature.STREAM
|
||||||
):
|
):
|
||||||
self.__supports_stream = supports_stream
|
self.__supports_stream = supports_stream
|
||||||
self._invalidate_camera_capabilities_cache()
|
self._invalidate_camera_capabilities_cache()
|
||||||
|
@@ -156,15 +156,6 @@ class CameraWebRTCProvider(ABC):
|
|||||||
"""Close the session."""
|
"""Close the session."""
|
||||||
return ## This is an optional method so we need a default here.
|
return ## This is an optional method so we need a default here.
|
||||||
|
|
||||||
async def async_get_image(
|
|
||||||
self,
|
|
||||||
camera: Camera,
|
|
||||||
width: int | None = None,
|
|
||||||
height: int | None = None,
|
|
||||||
) -> bytes | None:
|
|
||||||
"""Get an image from the camera."""
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_register_webrtc_provider(
|
def async_register_webrtc_provider(
|
||||||
|
@@ -105,6 +105,11 @@ DEFAULT_MAX_HUMIDITY = 99
|
|||||||
|
|
||||||
CONVERTIBLE_ATTRIBUTE = [ATTR_TEMPERATURE, ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH]
|
CONVERTIBLE_ATTRIBUTE = [ATTR_TEMPERATURE, ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH]
|
||||||
|
|
||||||
|
# Can be removed in 2025.1 after deprecation period of the new feature flags
|
||||||
|
CHECK_TURN_ON_OFF_FEATURE_FLAG = (
|
||||||
|
ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF
|
||||||
|
)
|
||||||
|
|
||||||
SET_TEMPERATURE_SCHEMA = vol.All(
|
SET_TEMPERATURE_SCHEMA = vol.All(
|
||||||
cv.has_at_least_one_key(
|
cv.has_at_least_one_key(
|
||||||
ATTR_TEMPERATURE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW
|
ATTR_TEMPERATURE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW
|
||||||
|
@@ -13,6 +13,6 @@
|
|||||||
"integration_type": "system",
|
"integration_type": "system",
|
||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["acme", "hass_nabucasa", "snitun"],
|
"loggers": ["acme", "hass_nabucasa", "snitun"],
|
||||||
"requirements": ["hass-nabucasa==0.104.0"],
|
"requirements": ["hass-nabucasa==0.101.0"],
|
||||||
"single_config_entry": true
|
"single_config_entry": true
|
||||||
}
|
}
|
||||||
|
@@ -7,18 +7,15 @@ import numpy as np
|
|||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||||
from homeassistant.config_entries import ConfigEntry
|
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
CONF_ATTRIBUTE,
|
CONF_ATTRIBUTE,
|
||||||
CONF_MAXIMUM,
|
CONF_MAXIMUM,
|
||||||
CONF_MINIMUM,
|
CONF_MINIMUM,
|
||||||
CONF_NAME,
|
|
||||||
CONF_SOURCE,
|
CONF_SOURCE,
|
||||||
CONF_UNIQUE_ID,
|
CONF_UNIQUE_ID,
|
||||||
CONF_UNIT_OF_MEASUREMENT,
|
CONF_UNIT_OF_MEASUREMENT,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryError
|
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.discovery import async_load_platform
|
from homeassistant.helpers.discovery import async_load_platform
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
@@ -35,7 +32,6 @@ from .const import (
|
|||||||
DEFAULT_DEGREE,
|
DEFAULT_DEGREE,
|
||||||
DEFAULT_PRECISION,
|
DEFAULT_PRECISION,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
PLATFORMS,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -81,104 +77,59 @@ CONFIG_SCHEMA = vol.Schema(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def create_compensation_data(
|
|
||||||
hass: HomeAssistant, compensation: str, conf: ConfigType, should_raise: bool = False
|
|
||||||
) -> None:
|
|
||||||
"""Create compensation data."""
|
|
||||||
_LOGGER.debug("Setup %s.%s", DOMAIN, compensation)
|
|
||||||
|
|
||||||
degree = conf[CONF_DEGREE]
|
|
||||||
|
|
||||||
initial_coefficients: list[tuple[float, float]] = conf[CONF_DATAPOINTS]
|
|
||||||
sorted_coefficients = sorted(initial_coefficients, key=itemgetter(0))
|
|
||||||
|
|
||||||
# get x values and y values from the x,y point pairs
|
|
||||||
x_values, y_values = zip(*initial_coefficients, strict=False)
|
|
||||||
|
|
||||||
# try to get valid coefficients for a polynomial
|
|
||||||
coefficients = None
|
|
||||||
with np.errstate(all="raise"):
|
|
||||||
try:
|
|
||||||
coefficients = np.polyfit(x_values, y_values, degree)
|
|
||||||
except FloatingPointError as error:
|
|
||||||
_LOGGER.error(
|
|
||||||
"Setup of %s encountered an error, %s",
|
|
||||||
compensation,
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
if should_raise:
|
|
||||||
raise ConfigEntryError(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="setup_error",
|
|
||||||
translation_placeholders={
|
|
||||||
"title": conf[CONF_NAME],
|
|
||||||
"error": str(error),
|
|
||||||
},
|
|
||||||
) from error
|
|
||||||
|
|
||||||
if coefficients is not None:
|
|
||||||
data = {
|
|
||||||
k: v for k, v in conf.items() if k not in [CONF_DEGREE, CONF_DATAPOINTS]
|
|
||||||
}
|
|
||||||
data[CONF_POLYNOMIAL] = np.poly1d(coefficients)
|
|
||||||
|
|
||||||
if data[CONF_LOWER_LIMIT]:
|
|
||||||
data[CONF_MINIMUM] = sorted_coefficients[0]
|
|
||||||
else:
|
|
||||||
data[CONF_MINIMUM] = None
|
|
||||||
|
|
||||||
if data[CONF_UPPER_LIMIT]:
|
|
||||||
data[CONF_MAXIMUM] = sorted_coefficients[-1]
|
|
||||||
else:
|
|
||||||
data[CONF_MAXIMUM] = None
|
|
||||||
|
|
||||||
hass.data[DATA_COMPENSATION][compensation] = data
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
"""Set up the Compensation sensor."""
|
"""Set up the Compensation sensor."""
|
||||||
hass.data[DATA_COMPENSATION] = {}
|
hass.data[DATA_COMPENSATION] = {}
|
||||||
|
|
||||||
if DOMAIN not in config:
|
|
||||||
return True
|
|
||||||
|
|
||||||
for compensation, conf in config[DOMAIN].items():
|
for compensation, conf in config[DOMAIN].items():
|
||||||
await create_compensation_data(hass, compensation, conf)
|
_LOGGER.debug("Setup %s.%s", DOMAIN, compensation)
|
||||||
hass.async_create_task(
|
|
||||||
async_load_platform(
|
degree = conf[CONF_DEGREE]
|
||||||
hass,
|
|
||||||
SENSOR_DOMAIN,
|
initial_coefficients: list[tuple[float, float]] = conf[CONF_DATAPOINTS]
|
||||||
DOMAIN,
|
sorted_coefficients = sorted(initial_coefficients, key=itemgetter(0))
|
||||||
{CONF_COMPENSATION: compensation},
|
|
||||||
config,
|
# get x values and y values from the x,y point pairs
|
||||||
|
x_values, y_values = zip(*initial_coefficients, strict=False)
|
||||||
|
|
||||||
|
# try to get valid coefficients for a polynomial
|
||||||
|
coefficients = None
|
||||||
|
with np.errstate(all="raise"):
|
||||||
|
try:
|
||||||
|
coefficients = np.polyfit(x_values, y_values, degree)
|
||||||
|
except FloatingPointError as error:
|
||||||
|
_LOGGER.error(
|
||||||
|
"Setup of %s encountered an error, %s",
|
||||||
|
compensation,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
|
||||||
|
if coefficients is not None:
|
||||||
|
data = {
|
||||||
|
k: v for k, v in conf.items() if k not in [CONF_DEGREE, CONF_DATAPOINTS]
|
||||||
|
}
|
||||||
|
data[CONF_POLYNOMIAL] = np.poly1d(coefficients)
|
||||||
|
|
||||||
|
if data[CONF_LOWER_LIMIT]:
|
||||||
|
data[CONF_MINIMUM] = sorted_coefficients[0]
|
||||||
|
else:
|
||||||
|
data[CONF_MINIMUM] = None
|
||||||
|
|
||||||
|
if data[CONF_UPPER_LIMIT]:
|
||||||
|
data[CONF_MAXIMUM] = sorted_coefficients[-1]
|
||||||
|
else:
|
||||||
|
data[CONF_MAXIMUM] = None
|
||||||
|
|
||||||
|
hass.data[DATA_COMPENSATION][compensation] = data
|
||||||
|
|
||||||
|
hass.async_create_task(
|
||||||
|
async_load_platform(
|
||||||
|
hass,
|
||||||
|
SENSOR_DOMAIN,
|
||||||
|
DOMAIN,
|
||||||
|
{CONF_COMPENSATION: compensation},
|
||||||
|
config,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
||||||
"""Set up Compensation from a config entry."""
|
|
||||||
config = dict(entry.options)
|
|
||||||
data_points = config[CONF_DATAPOINTS]
|
|
||||||
new_data_points = []
|
|
||||||
for data_point in data_points:
|
|
||||||
values = data_point.split(",", maxsplit=1)
|
|
||||||
new_data_points.append([float(values[0]), float(values[1])])
|
|
||||||
config[CONF_DATAPOINTS] = new_data_points
|
|
||||||
|
|
||||||
await create_compensation_data(hass, entry.entry_id, config, True)
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
|
||||||
entry.async_on_unload(entry.add_update_listener(update_listener))
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
||||||
"""Unload Compensation config entry."""
|
|
||||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
|
||||||
|
|
||||||
|
|
||||||
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|
||||||
"""Handle options update."""
|
|
||||||
await hass.config_entries.async_reload(entry.entry_id)
|
|
||||||
|
@@ -1,147 +0,0 @@
|
|||||||
"""Config flow for statistics."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Mapping
|
|
||||||
from typing import Any, cast
|
|
||||||
|
|
||||||
import voluptuous as vol
|
|
||||||
|
|
||||||
from homeassistant.const import (
|
|
||||||
CONF_ATTRIBUTE,
|
|
||||||
CONF_ENTITY_ID,
|
|
||||||
CONF_NAME,
|
|
||||||
CONF_UNIT_OF_MEASUREMENT,
|
|
||||||
)
|
|
||||||
from homeassistant.helpers.schema_config_entry_flow import (
|
|
||||||
SchemaCommonFlowHandler,
|
|
||||||
SchemaConfigFlowHandler,
|
|
||||||
SchemaFlowError,
|
|
||||||
SchemaFlowFormStep,
|
|
||||||
)
|
|
||||||
from homeassistant.helpers.selector import (
|
|
||||||
AttributeSelector,
|
|
||||||
AttributeSelectorConfig,
|
|
||||||
BooleanSelector,
|
|
||||||
EntitySelector,
|
|
||||||
NumberSelector,
|
|
||||||
NumberSelectorConfig,
|
|
||||||
NumberSelectorMode,
|
|
||||||
SelectSelector,
|
|
||||||
SelectSelectorConfig,
|
|
||||||
SelectSelectorMode,
|
|
||||||
TextSelector,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .const import (
|
|
||||||
CONF_DATAPOINTS,
|
|
||||||
CONF_DEGREE,
|
|
||||||
CONF_LOWER_LIMIT,
|
|
||||||
CONF_PRECISION,
|
|
||||||
CONF_UPPER_LIMIT,
|
|
||||||
DEFAULT_DEGREE,
|
|
||||||
DEFAULT_NAME,
|
|
||||||
DEFAULT_PRECISION,
|
|
||||||
DOMAIN,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema:
|
|
||||||
"""Get options schema."""
|
|
||||||
entity_id = handler.options[CONF_ENTITY_ID]
|
|
||||||
|
|
||||||
return vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required(CONF_DATAPOINTS): SelectSelector(
|
|
||||||
SelectSelectorConfig(
|
|
||||||
options=[],
|
|
||||||
multiple=True,
|
|
||||||
custom_value=True,
|
|
||||||
mode=SelectSelectorMode.DROPDOWN,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
vol.Optional(CONF_ATTRIBUTE): AttributeSelector(
|
|
||||||
AttributeSelectorConfig(entity_id=entity_id)
|
|
||||||
),
|
|
||||||
vol.Optional(CONF_UPPER_LIMIT, default=False): BooleanSelector(),
|
|
||||||
vol.Optional(CONF_LOWER_LIMIT, default=False): BooleanSelector(),
|
|
||||||
vol.Optional(CONF_PRECISION, default=DEFAULT_PRECISION): NumberSelector(
|
|
||||||
NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX)
|
|
||||||
),
|
|
||||||
vol.Optional(CONF_DEGREE, default=DEFAULT_DEGREE): NumberSelector(
|
|
||||||
NumberSelectorConfig(min=0, max=7, step=1, mode=NumberSelectorMode.BOX)
|
|
||||||
),
|
|
||||||
vol.Optional(CONF_UNIT_OF_MEASUREMENT): TextSelector(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_valid_data_points(check_data_points: list[str]) -> bool:
|
|
||||||
"""Validate data points."""
|
|
||||||
result = False
|
|
||||||
for data_point in check_data_points:
|
|
||||||
if not data_point.find(",") > 0:
|
|
||||||
return False
|
|
||||||
values = data_point.split(",", maxsplit=1)
|
|
||||||
for value in values:
|
|
||||||
try:
|
|
||||||
float(value)
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
result = True
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def validate_options(
|
|
||||||
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Validate options selected."""
|
|
||||||
|
|
||||||
user_input[CONF_PRECISION] = int(user_input[CONF_PRECISION])
|
|
||||||
user_input[CONF_DEGREE] = int(user_input[CONF_DEGREE])
|
|
||||||
|
|
||||||
if not _is_valid_data_points(user_input[CONF_DATAPOINTS]):
|
|
||||||
raise SchemaFlowError("incorrect_datapoints")
|
|
||||||
|
|
||||||
if len(user_input[CONF_DATAPOINTS]) <= user_input[CONF_DEGREE]:
|
|
||||||
raise SchemaFlowError("not_enough_datapoints")
|
|
||||||
|
|
||||||
handler.parent_handler._async_abort_entries_match({**handler.options, **user_input}) # noqa: SLF001
|
|
||||||
|
|
||||||
return user_input
|
|
||||||
|
|
||||||
|
|
||||||
DATA_SCHEMA_SETUP = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(),
|
|
||||||
vol.Required(CONF_ENTITY_ID): EntitySelector(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
CONFIG_FLOW = {
|
|
||||||
"user": SchemaFlowFormStep(
|
|
||||||
schema=DATA_SCHEMA_SETUP,
|
|
||||||
next_step="options",
|
|
||||||
),
|
|
||||||
"options": SchemaFlowFormStep(
|
|
||||||
schema=get_options_schema,
|
|
||||||
validate_user_input=validate_options,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
OPTIONS_FLOW = {
|
|
||||||
"init": SchemaFlowFormStep(
|
|
||||||
get_options_schema,
|
|
||||||
validate_user_input=validate_options,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class CompensationConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
|
|
||||||
"""Handle a config flow for Compensation."""
|
|
||||||
|
|
||||||
config_flow = CONFIG_FLOW
|
|
||||||
options_flow = OPTIONS_FLOW
|
|
||||||
|
|
||||||
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
|
|
||||||
"""Return config entry title."""
|
|
||||||
return cast(str, options[CONF_NAME])
|
|
@@ -1,9 +1,6 @@
|
|||||||
"""Compensation constants."""
|
"""Compensation constants."""
|
||||||
|
|
||||||
from homeassistant.const import Platform
|
|
||||||
|
|
||||||
DOMAIN = "compensation"
|
DOMAIN = "compensation"
|
||||||
PLATFORMS = [Platform.SENSOR]
|
|
||||||
|
|
||||||
SENSOR = "compensation"
|
SENSOR = "compensation"
|
||||||
|
|
||||||
|
@@ -2,9 +2,7 @@
|
|||||||
"domain": "compensation",
|
"domain": "compensation",
|
||||||
"name": "Compensation",
|
"name": "Compensation",
|
||||||
"codeowners": ["@Petro31"],
|
"codeowners": ["@Petro31"],
|
||||||
"config_flow": true,
|
|
||||||
"documentation": "https://www.home-assistant.io/integrations/compensation",
|
"documentation": "https://www.home-assistant.io/integrations/compensation",
|
||||||
"integration_type": "helper",
|
|
||||||
"iot_class": "calculated",
|
"iot_class": "calculated",
|
||||||
"quality_scale": "legacy",
|
"quality_scale": "legacy",
|
||||||
"requirements": ["numpy==2.3.0"]
|
"requirements": ["numpy==2.3.0"]
|
||||||
|
@@ -8,11 +8,9 @@ from typing import Any
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from homeassistant.components.sensor import SensorEntity
|
from homeassistant.components.sensor import SensorEntity
|
||||||
from homeassistant.config_entries import ConfigEntry
|
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
ATTR_UNIT_OF_MEASUREMENT,
|
ATTR_UNIT_OF_MEASUREMENT,
|
||||||
CONF_ATTRIBUTE,
|
CONF_ATTRIBUTE,
|
||||||
CONF_ENTITY_ID,
|
|
||||||
CONF_MAXIMUM,
|
CONF_MAXIMUM,
|
||||||
CONF_MINIMUM,
|
CONF_MINIMUM,
|
||||||
CONF_SOURCE,
|
CONF_SOURCE,
|
||||||
@@ -82,36 +80,6 @@ async def async_setup_platform(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
entry: ConfigEntry,
|
|
||||||
async_add_entities: AddEntitiesCallback,
|
|
||||||
) -> None:
|
|
||||||
"""Set up the Compensation sensor entry."""
|
|
||||||
compensation = entry.entry_id
|
|
||||||
conf: dict[str, Any] = hass.data[DATA_COMPENSATION][compensation]
|
|
||||||
|
|
||||||
source: str = conf[CONF_ENTITY_ID]
|
|
||||||
attribute: str | None = conf.get(CONF_ATTRIBUTE)
|
|
||||||
name = entry.title
|
|
||||||
|
|
||||||
async_add_entities(
|
|
||||||
[
|
|
||||||
CompensationSensor(
|
|
||||||
entry.entry_id,
|
|
||||||
name,
|
|
||||||
source,
|
|
||||||
attribute,
|
|
||||||
conf[CONF_PRECISION],
|
|
||||||
conf[CONF_POLYNOMIAL],
|
|
||||||
conf.get(CONF_UNIT_OF_MEASUREMENT),
|
|
||||||
conf[CONF_MINIMUM],
|
|
||||||
conf[CONF_MAXIMUM],
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CompensationSensor(SensorEntity):
|
class CompensationSensor(SensorEntity):
|
||||||
"""Representation of a Compensation sensor."""
|
"""Representation of a Compensation sensor."""
|
||||||
|
|
||||||
|
@@ -1,82 +0,0 @@
|
|||||||
{
|
|
||||||
"config": {
|
|
||||||
"abort": {
|
|
||||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"incorrect_datapoints": "Datapoints needs to be provided in the right format, ex. '1.0, 0.0'.",
|
|
||||||
"not_enough_datapoints": "The number of datapoints needs to be more than the configured degree."
|
|
||||||
},
|
|
||||||
"step": {
|
|
||||||
"user": {
|
|
||||||
"description": "Add a compensation sensor",
|
|
||||||
"data": {
|
|
||||||
"name": "[%key:common::config_flow::data::name%]",
|
|
||||||
"entity_id": "Entity"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"name": "Name for the created entity.",
|
|
||||||
"entity_id": "Entity to use as source."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"options": {
|
|
||||||
"description": "Read the documention for further details on how to configure the statistics sensor using these options.",
|
|
||||||
"data": {
|
|
||||||
"data_points": "Data points",
|
|
||||||
"attribute": "Attribute",
|
|
||||||
"upper_limit": "Upper limit",
|
|
||||||
"lower_limit": "Lower limit",
|
|
||||||
"precision": "Precision",
|
|
||||||
"degree": "Degree",
|
|
||||||
"unit_of_measurement": "Unit of measurement"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"data_points": "The collection of data point conversions with the format 'uncompensated_value, compensated_value', ex. '1.0, 0.0'",
|
|
||||||
"attribute": "Attribute from the source to monitor/compensate.",
|
|
||||||
"upper_limit": "Enables an upper limit for the sensor. The upper limit is defined by the data collections (data_points) greatest uncompensated value.",
|
|
||||||
"lower_limit": "Enables a lower limit for the sensor. The lower limit is defined by the data collections (data_points) lowest uncompensated value.",
|
|
||||||
"precision": "Defines the precision of the calculated values, through the argument of round().",
|
|
||||||
"degree": "The degree of a polynomial.",
|
|
||||||
"unit_of_measurement": "Defines the units of measurement of the sensor, if any."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"options": {
|
|
||||||
"abort": {
|
|
||||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"incorrect_datapoints": "[%key:component::compensation::config::error::incorrect_datapoints%]",
|
|
||||||
"not_enough_datapoints": "[%key:component::compensation::config::error::not_enough_datapoints%]"
|
|
||||||
},
|
|
||||||
"step": {
|
|
||||||
"init": {
|
|
||||||
"description": "[%key:component::compensation::config::step::options::description%]",
|
|
||||||
"data": {
|
|
||||||
"data_points": "[%key:component::compensation::config::step::options::data::data_points%]",
|
|
||||||
"attribute": "[%key:component::compensation::config::step::options::data::attribute%]",
|
|
||||||
"upper_limit": "[%key:component::compensation::config::step::options::data::upper_limit%]",
|
|
||||||
"lower_limit": "[%key:component::compensation::config::step::options::data::lower_limit%]",
|
|
||||||
"precision": "[%key:component::compensation::config::step::options::data::precision%]",
|
|
||||||
"degree": "[%key:component::compensation::config::step::options::data::degree%]",
|
|
||||||
"unit_of_measurement": "[%key:component::compensation::config::step::options::data::unit_of_measurement%]"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"data_points": "[%key:component::compensation::config::step::options::data_description::data_points%]",
|
|
||||||
"attribute": "[%key:component::compensation::config::step::options::data_description::attribute%]",
|
|
||||||
"upper_limit": "[%key:component::compensation::config::step::options::data_description::upper_limit%]",
|
|
||||||
"lower_limit": "[%key:component::compensation::config::step::options::data_description::lower_limit%]",
|
|
||||||
"precision": "[%key:component::compensation::config::step::options::data_description::precision%]",
|
|
||||||
"degree": "[%key:component::compensation::config::step::options::data_description::degree%]",
|
|
||||||
"unit_of_measurement": "[%key:component::compensation::config::step::options::data_description::unit_of_measurement%]"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"exceptions": {
|
|
||||||
"setup_error": {
|
|
||||||
"message": "Setup of {title} could not be setup due to {error}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -54,10 +54,10 @@ class Control4RuntimeData:
|
|||||||
type Control4ConfigEntry = ConfigEntry[Control4RuntimeData]
|
type Control4ConfigEntry = ConfigEntry[Control4RuntimeData]
|
||||||
|
|
||||||
|
|
||||||
async def call_c4_api_retry(func, *func_args): # noqa: RET503
|
async def call_c4_api_retry(func, *func_args):
|
||||||
"""Call C4 API function and retry on failure."""
|
"""Call C4 API function and retry on failure."""
|
||||||
# Ruff doesn't understand this loop - the exception is always raised after the retries
|
# Ruff doesn't understand this loop - the exception is always raised after the retries
|
||||||
for i in range(API_RETRY_TIMES):
|
for i in range(API_RETRY_TIMES): # noqa: RET503
|
||||||
try:
|
try:
|
||||||
return await func(*func_args)
|
return await func(*func_args)
|
||||||
except client_exceptions.ClientError as exception:
|
except client_exceptions.ClientError as exception:
|
||||||
|
@@ -271,7 +271,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Temporary migration. We can remove this in 2024.10
|
# Temporary migration. We can remove this in 2024.10
|
||||||
from homeassistant.components.assist_pipeline import ( # noqa: PLC0415
|
from homeassistant.components.assist_pipeline import ( # pylint: disable=import-outside-toplevel
|
||||||
async_migrate_engine,
|
async_migrate_engine,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@@ -6,5 +6,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||||
"integration_type": "system",
|
"integration_type": "system",
|
||||||
"quality_scale": "internal",
|
"quality_scale": "internal",
|
||||||
"requirements": ["hassil==2.2.3", "home-assistant-intents==2025.6.23"]
|
"requirements": ["hassil==2.2.3", "home-assistant-intents==2025.6.10"]
|
||||||
}
|
}
|
||||||
|
@@ -300,6 +300,10 @@ class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
|||||||
def supported_features(self) -> CoverEntityFeature:
|
def supported_features(self) -> CoverEntityFeature:
|
||||||
"""Flag supported features."""
|
"""Flag supported features."""
|
||||||
if (features := self._attr_supported_features) is not None:
|
if (features := self._attr_supported_features) is not None:
|
||||||
|
if type(features) is int:
|
||||||
|
new_features = CoverEntityFeature(features)
|
||||||
|
self._report_deprecated_supported_features_values(new_features)
|
||||||
|
return new_features
|
||||||
return features
|
return features
|
||||||
|
|
||||||
supported_features = (
|
supported_features = (
|
||||||
|
@@ -26,7 +26,6 @@ from homeassistant.helpers.schema_config_entry_flow import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_MAX_SUB_INTERVAL,
|
|
||||||
CONF_ROUND_DIGITS,
|
CONF_ROUND_DIGITS,
|
||||||
CONF_TIME_WINDOW,
|
CONF_TIME_WINDOW,
|
||||||
CONF_UNIT_PREFIX,
|
CONF_UNIT_PREFIX,
|
||||||
@@ -105,9 +104,6 @@ async def _get_options_dict(handler: SchemaCommonFlowHandler | None) -> dict:
|
|||||||
options=TIME_UNITS, translation_key="time_unit"
|
options=TIME_UNITS, translation_key="time_unit"
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
vol.Optional(CONF_MAX_SUB_INTERVAL): selector.DurationSelector(
|
|
||||||
selector.DurationSelectorConfig(allow_negative=False)
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@@ -7,4 +7,3 @@ CONF_TIME_WINDOW = "time_window"
|
|||||||
CONF_UNIT = "unit"
|
CONF_UNIT = "unit"
|
||||||
CONF_UNIT_PREFIX = "unit_prefix"
|
CONF_UNIT_PREFIX = "unit_prefix"
|
||||||
CONF_UNIT_TIME = "unit_time"
|
CONF_UNIT_TIME = "unit_time"
|
||||||
CONF_MAX_SUB_INTERVAL = "max_sub_interval"
|
|
||||||
|
@@ -2,7 +2,7 @@
|
|||||||
"domain": "derivative",
|
"domain": "derivative",
|
||||||
"name": "Derivative",
|
"name": "Derivative",
|
||||||
"after_dependencies": ["counter"],
|
"after_dependencies": ["counter"],
|
||||||
"codeowners": ["@afaucogney", "@karwosts"],
|
"codeowners": ["@afaucogney"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/derivative",
|
"documentation": "https://www.home-assistant.io/integrations/derivative",
|
||||||
"integration_type": "helper",
|
"integration_type": "helper",
|
||||||
|
@@ -3,7 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from decimal import Decimal, DecimalException, InvalidOperation
|
from decimal import Decimal, DecimalException
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
@@ -25,7 +25,6 @@ from homeassistant.const import (
|
|||||||
UnitOfTime,
|
UnitOfTime,
|
||||||
)
|
)
|
||||||
from homeassistant.core import (
|
from homeassistant.core import (
|
||||||
CALLBACK_TYPE,
|
|
||||||
Event,
|
Event,
|
||||||
EventStateChangedData,
|
EventStateChangedData,
|
||||||
EventStateReportedData,
|
EventStateReportedData,
|
||||||
@@ -41,14 +40,12 @@ from homeassistant.helpers.entity_platform import (
|
|||||||
AddEntitiesCallback,
|
AddEntitiesCallback,
|
||||||
)
|
)
|
||||||
from homeassistant.helpers.event import (
|
from homeassistant.helpers.event import (
|
||||||
async_call_later,
|
|
||||||
async_track_state_change_event,
|
async_track_state_change_event,
|
||||||
async_track_state_report_event,
|
async_track_state_report_event,
|
||||||
)
|
)
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_MAX_SUB_INTERVAL,
|
|
||||||
CONF_ROUND_DIGITS,
|
CONF_ROUND_DIGITS,
|
||||||
CONF_TIME_WINDOW,
|
CONF_TIME_WINDOW,
|
||||||
CONF_UNIT,
|
CONF_UNIT,
|
||||||
@@ -92,20 +89,10 @@ PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
|
|||||||
vol.Optional(CONF_UNIT_TIME, default=UnitOfTime.HOURS): vol.In(UNIT_TIME),
|
vol.Optional(CONF_UNIT_TIME, default=UnitOfTime.HOURS): vol.In(UNIT_TIME),
|
||||||
vol.Optional(CONF_UNIT): cv.string,
|
vol.Optional(CONF_UNIT): cv.string,
|
||||||
vol.Optional(CONF_TIME_WINDOW, default=DEFAULT_TIME_WINDOW): cv.time_period,
|
vol.Optional(CONF_TIME_WINDOW, default=DEFAULT_TIME_WINDOW): cv.time_period,
|
||||||
vol.Optional(CONF_MAX_SUB_INTERVAL): cv.positive_time_period,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _is_decimal_state(state: str) -> bool:
|
|
||||||
try:
|
|
||||||
Decimal(state)
|
|
||||||
except (InvalidOperation, TypeError):
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
config_entry: ConfigEntry,
|
config_entry: ConfigEntry,
|
||||||
@@ -127,11 +114,6 @@ async def async_setup_entry(
|
|||||||
# Before we had support for optional selectors, "none" was used for selecting nothing
|
# Before we had support for optional selectors, "none" was used for selecting nothing
|
||||||
unit_prefix = None
|
unit_prefix = None
|
||||||
|
|
||||||
if max_sub_interval_dict := config_entry.options.get(CONF_MAX_SUB_INTERVAL, None):
|
|
||||||
max_sub_interval = cv.time_period(max_sub_interval_dict)
|
|
||||||
else:
|
|
||||||
max_sub_interval = None
|
|
||||||
|
|
||||||
derivative_sensor = DerivativeSensor(
|
derivative_sensor = DerivativeSensor(
|
||||||
name=config_entry.title,
|
name=config_entry.title,
|
||||||
round_digits=int(config_entry.options[CONF_ROUND_DIGITS]),
|
round_digits=int(config_entry.options[CONF_ROUND_DIGITS]),
|
||||||
@@ -142,7 +124,6 @@ async def async_setup_entry(
|
|||||||
unit_prefix=unit_prefix,
|
unit_prefix=unit_prefix,
|
||||||
unit_time=config_entry.options[CONF_UNIT_TIME],
|
unit_time=config_entry.options[CONF_UNIT_TIME],
|
||||||
device_info=device_info,
|
device_info=device_info,
|
||||||
max_sub_interval=max_sub_interval,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async_add_entities([derivative_sensor])
|
async_add_entities([derivative_sensor])
|
||||||
@@ -164,7 +145,6 @@ async def async_setup_platform(
|
|||||||
unit_prefix=config[CONF_UNIT_PREFIX],
|
unit_prefix=config[CONF_UNIT_PREFIX],
|
||||||
unit_time=config[CONF_UNIT_TIME],
|
unit_time=config[CONF_UNIT_TIME],
|
||||||
unique_id=None,
|
unique_id=None,
|
||||||
max_sub_interval=config.get(CONF_MAX_SUB_INTERVAL),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async_add_entities([derivative])
|
async_add_entities([derivative])
|
||||||
@@ -186,7 +166,6 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
|||||||
unit_of_measurement: str | None,
|
unit_of_measurement: str | None,
|
||||||
unit_prefix: str | None,
|
unit_prefix: str | None,
|
||||||
unit_time: UnitOfTime,
|
unit_time: UnitOfTime,
|
||||||
max_sub_interval: timedelta | None,
|
|
||||||
unique_id: str | None,
|
unique_id: str | None,
|
||||||
device_info: DeviceInfo | None = None,
|
device_info: DeviceInfo | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -213,34 +192,6 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
|||||||
self._unit_prefix = UNIT_PREFIXES[unit_prefix]
|
self._unit_prefix = UNIT_PREFIXES[unit_prefix]
|
||||||
self._unit_time = UNIT_TIME[unit_time]
|
self._unit_time = UNIT_TIME[unit_time]
|
||||||
self._time_window = time_window.total_seconds()
|
self._time_window = time_window.total_seconds()
|
||||||
self._max_sub_interval: timedelta | None = (
|
|
||||||
None # disable time based derivative
|
|
||||||
if max_sub_interval is None or max_sub_interval.total_seconds() == 0
|
|
||||||
else max_sub_interval
|
|
||||||
)
|
|
||||||
self._cancel_max_sub_interval_exceeded_callback: CALLBACK_TYPE = (
|
|
||||||
lambda *args: None
|
|
||||||
)
|
|
||||||
|
|
||||||
def _calc_derivative_from_state_list(self, current_time: datetime) -> Decimal:
|
|
||||||
def calculate_weight(start: datetime, end: datetime, now: datetime) -> float:
|
|
||||||
window_start = now - timedelta(seconds=self._time_window)
|
|
||||||
return (end - max(start, window_start)).total_seconds() / self._time_window
|
|
||||||
|
|
||||||
derivative = Decimal("0.00")
|
|
||||||
for start, end, value in self._state_list:
|
|
||||||
weight = calculate_weight(start, end, current_time)
|
|
||||||
derivative = derivative + (value * Decimal(weight))
|
|
||||||
|
|
||||||
return derivative
|
|
||||||
|
|
||||||
def _prune_state_list(self, current_time: datetime) -> None:
|
|
||||||
# filter out all derivatives older than `time_window` from our window list
|
|
||||||
self._state_list = [
|
|
||||||
(time_start, time_end, state)
|
|
||||||
for time_start, time_end, state in self._state_list
|
|
||||||
if (current_time - time_end).total_seconds() < self._time_window
|
|
||||||
]
|
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Handle entity which will be added."""
|
"""Handle entity which will be added."""
|
||||||
@@ -258,52 +209,13 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
|||||||
except SyntaxError as err:
|
except SyntaxError as err:
|
||||||
_LOGGER.warning("Could not restore last state: %s", err)
|
_LOGGER.warning("Could not restore last state: %s", err)
|
||||||
|
|
||||||
def schedule_max_sub_interval_exceeded(source_state: State | None) -> None:
|
|
||||||
"""Schedule calculation using the source state and max_sub_interval.
|
|
||||||
|
|
||||||
The callback reference is stored for possible cancellation if the source state
|
|
||||||
reports a change before max_sub_interval has passed.
|
|
||||||
If the callback is executed, meaning there was no state change reported, the
|
|
||||||
source_state is assumed constant and calculation is done using its value.
|
|
||||||
"""
|
|
||||||
if (
|
|
||||||
self._max_sub_interval is not None
|
|
||||||
and source_state is not None
|
|
||||||
and (_is_decimal_state(source_state.state))
|
|
||||||
):
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def _calc_derivative_on_max_sub_interval_exceeded_callback(
|
|
||||||
now: datetime,
|
|
||||||
) -> None:
|
|
||||||
"""Calculate derivative based on time and reschedule."""
|
|
||||||
|
|
||||||
self._prune_state_list(now)
|
|
||||||
derivative = self._calc_derivative_from_state_list(now)
|
|
||||||
self._attr_native_value = round(derivative, self._round_digits)
|
|
||||||
|
|
||||||
self.async_write_ha_state()
|
|
||||||
|
|
||||||
# If derivative is now zero, don't schedule another timeout callback, as it will have no effect
|
|
||||||
if derivative != 0:
|
|
||||||
schedule_max_sub_interval_exceeded(source_state)
|
|
||||||
|
|
||||||
self._cancel_max_sub_interval_exceeded_callback = async_call_later(
|
|
||||||
self.hass,
|
|
||||||
self._max_sub_interval,
|
|
||||||
_calc_derivative_on_max_sub_interval_exceeded_callback,
|
|
||||||
)
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def on_state_reported(event: Event[EventStateReportedData]) -> None:
|
def on_state_reported(event: Event[EventStateReportedData]) -> None:
|
||||||
"""Handle constant sensor state."""
|
"""Handle constant sensor state."""
|
||||||
self._cancel_max_sub_interval_exceeded_callback()
|
|
||||||
new_state = event.data["new_state"]
|
|
||||||
if self._attr_native_value == Decimal(0):
|
if self._attr_native_value == Decimal(0):
|
||||||
# If the derivative is zero, and the source sensor hasn't
|
# If the derivative is zero, and the source sensor hasn't
|
||||||
# changed state, then we know it will still be zero.
|
# changed state, then we know it will still be zero.
|
||||||
return
|
return
|
||||||
schedule_max_sub_interval_exceeded(new_state)
|
|
||||||
new_state = event.data["new_state"]
|
new_state = event.data["new_state"]
|
||||||
if new_state is not None:
|
if new_state is not None:
|
||||||
calc_derivative(
|
calc_derivative(
|
||||||
@@ -313,9 +225,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
|||||||
@callback
|
@callback
|
||||||
def on_state_changed(event: Event[EventStateChangedData]) -> None:
|
def on_state_changed(event: Event[EventStateChangedData]) -> None:
|
||||||
"""Handle changed sensor state."""
|
"""Handle changed sensor state."""
|
||||||
self._cancel_max_sub_interval_exceeded_callback()
|
|
||||||
new_state = event.data["new_state"]
|
new_state = event.data["new_state"]
|
||||||
schedule_max_sub_interval_exceeded(new_state)
|
|
||||||
old_state = event.data["old_state"]
|
old_state = event.data["old_state"]
|
||||||
if new_state is not None and old_state is not None:
|
if new_state is not None and old_state is not None:
|
||||||
calc_derivative(new_state, old_state.state, old_state.last_reported)
|
calc_derivative(new_state, old_state.state, old_state.last_reported)
|
||||||
@@ -336,7 +246,13 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
|||||||
"" if unit is None else unit
|
"" if unit is None else unit
|
||||||
)
|
)
|
||||||
|
|
||||||
self._prune_state_list(new_state.last_reported)
|
# filter out all derivatives older than `time_window` from our window list
|
||||||
|
self._state_list = [
|
||||||
|
(time_start, time_end, state)
|
||||||
|
for time_start, time_end, state in self._state_list
|
||||||
|
if (new_state.last_reported - time_end).total_seconds()
|
||||||
|
< self._time_window
|
||||||
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
elapsed_time = (
|
elapsed_time = (
|
||||||
@@ -374,27 +290,28 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
|||||||
(old_last_reported, new_state.last_reported, new_derivative)
|
(old_last_reported, new_state.last_reported, new_derivative)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def calculate_weight(
|
||||||
|
start: datetime, end: datetime, now: datetime
|
||||||
|
) -> float:
|
||||||
|
window_start = now - timedelta(seconds=self._time_window)
|
||||||
|
if start < window_start:
|
||||||
|
weight = (end - window_start).total_seconds() / self._time_window
|
||||||
|
else:
|
||||||
|
weight = (end - start).total_seconds() / self._time_window
|
||||||
|
return weight
|
||||||
|
|
||||||
# If outside of time window just report derivative (is the same as modeling it in the window),
|
# If outside of time window just report derivative (is the same as modeling it in the window),
|
||||||
# otherwise take the weighted average with the previous derivatives
|
# otherwise take the weighted average with the previous derivatives
|
||||||
if elapsed_time > self._time_window:
|
if elapsed_time > self._time_window:
|
||||||
derivative = new_derivative
|
derivative = new_derivative
|
||||||
else:
|
else:
|
||||||
derivative = self._calc_derivative_from_state_list(
|
derivative = Decimal("0.00")
|
||||||
new_state.last_reported
|
for start, end, value in self._state_list:
|
||||||
)
|
weight = calculate_weight(start, end, new_state.last_reported)
|
||||||
|
derivative = derivative + (value * Decimal(weight))
|
||||||
self._attr_native_value = round(derivative, self._round_digits)
|
self._attr_native_value = round(derivative, self._round_digits)
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
if self._max_sub_interval is not None:
|
|
||||||
source_state = self.hass.states.get(self._sensor_source_id)
|
|
||||||
schedule_max_sub_interval_exceeded(source_state)
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def on_removed() -> None:
|
|
||||||
self._cancel_max_sub_interval_exceeded_callback()
|
|
||||||
|
|
||||||
self.async_on_remove(on_removed)
|
|
||||||
|
|
||||||
self.async_on_remove(
|
self.async_on_remove(
|
||||||
async_track_state_change_event(
|
async_track_state_change_event(
|
||||||
self.hass, self._sensor_source_id, on_state_changed
|
self.hass, self._sensor_source_id, on_state_changed
|
||||||
|
@@ -6,7 +6,6 @@
|
|||||||
"title": "Create Derivative sensor",
|
"title": "Create Derivative sensor",
|
||||||
"description": "Create a sensor that estimates the derivative of a sensor.",
|
"description": "Create a sensor that estimates the derivative of a sensor.",
|
||||||
"data": {
|
"data": {
|
||||||
"max_sub_interval": "Max sub-interval",
|
|
||||||
"name": "[%key:common::config_flow::data::name%]",
|
"name": "[%key:common::config_flow::data::name%]",
|
||||||
"round": "Precision",
|
"round": "Precision",
|
||||||
"source": "Input sensor",
|
"source": "Input sensor",
|
||||||
@@ -15,7 +14,6 @@
|
|||||||
"unit_time": "Time unit"
|
"unit_time": "Time unit"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"max_sub_interval": "If defined, derivative automatically recalculates if the source has not updated for this duration.",
|
|
||||||
"round": "Controls the number of decimal digits in the output.",
|
"round": "Controls the number of decimal digits in the output.",
|
||||||
"time_window": "If set, the sensor's value is a time-weighted moving average of derivatives within this window.",
|
"time_window": "If set, the sensor's value is a time-weighted moving average of derivatives within this window.",
|
||||||
"unit_prefix": "The output will be scaled according to the selected metric prefix and time unit of the derivative."
|
"unit_prefix": "The output will be scaled according to the selected metric prefix and time unit of the derivative."
|
||||||
@@ -27,7 +25,6 @@
|
|||||||
"step": {
|
"step": {
|
||||||
"init": {
|
"init": {
|
||||||
"data": {
|
"data": {
|
||||||
"max_sub_interval": "[%key:component::derivative::config::step::user::data::max_sub_interval%]",
|
|
||||||
"name": "[%key:common::config_flow::data::name%]",
|
"name": "[%key:common::config_flow::data::name%]",
|
||||||
"round": "[%key:component::derivative::config::step::user::data::round%]",
|
"round": "[%key:component::derivative::config::step::user::data::round%]",
|
||||||
"source": "[%key:component::derivative::config::step::user::data::source%]",
|
"source": "[%key:component::derivative::config::step::user::data::source%]",
|
||||||
@@ -36,7 +33,6 @@
|
|||||||
"unit_time": "[%key:component::derivative::config::step::user::data::unit_time%]"
|
"unit_time": "[%key:component::derivative::config::step::user::data::unit_time%]"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"max_sub_interval": "[%key:component::derivative::config::step::user::data_description::max_sub_interval%]",
|
|
||||||
"round": "[%key:component::derivative::config::step::user::data_description::round%]",
|
"round": "[%key:component::derivative::config::step::user::data_description::round%]",
|
||||||
"time_window": "[%key:component::derivative::config::step::user::data_description::time_window%]",
|
"time_window": "[%key:component::derivative::config::step::user::data_description::time_window%]",
|
||||||
"unit_prefix": "[%key:component::derivative::config::step::user::data_description::unit_prefix%]"
|
"unit_prefix": "[%key:component::derivative::config::step::user::data_description::unit_prefix%]"
|
||||||
|
@@ -9,11 +9,7 @@ import voluptuous as vol
|
|||||||
from homeassistant.const import CONF_DOMAIN
|
from homeassistant.const import CONF_DOMAIN
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.condition import (
|
from homeassistant.helpers.condition import ConditionProtocol, trace_condition_function
|
||||||
Condition,
|
|
||||||
ConditionCheckerType,
|
|
||||||
trace_condition_function,
|
|
||||||
)
|
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
from . import DeviceAutomationType, async_get_device_automation_platform
|
from . import DeviceAutomationType, async_get_device_automation_platform
|
||||||
@@ -23,24 +19,13 @@ if TYPE_CHECKING:
|
|||||||
from homeassistant.helpers import condition
|
from homeassistant.helpers import condition
|
||||||
|
|
||||||
|
|
||||||
class DeviceAutomationConditionProtocol(Protocol):
|
class DeviceAutomationConditionProtocol(ConditionProtocol, Protocol):
|
||||||
"""Define the format of device_condition modules.
|
"""Define the format of device_condition modules.
|
||||||
|
|
||||||
Each module must define either CONDITION_SCHEMA or async_validate_condition_config.
|
Each module must define either CONDITION_SCHEMA or async_validate_condition_config
|
||||||
|
from ConditionProtocol.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
CONDITION_SCHEMA: vol.Schema
|
|
||||||
|
|
||||||
async def async_validate_condition_config(
|
|
||||||
self, hass: HomeAssistant, config: ConfigType
|
|
||||||
) -> ConfigType:
|
|
||||||
"""Validate config."""
|
|
||||||
|
|
||||||
def async_condition_from_config(
|
|
||||||
self, hass: HomeAssistant, config: ConfigType
|
|
||||||
) -> ConditionCheckerType:
|
|
||||||
"""Evaluate state based on configuration."""
|
|
||||||
|
|
||||||
async def async_get_condition_capabilities(
|
async def async_get_condition_capabilities(
|
||||||
self, hass: HomeAssistant, config: ConfigType
|
self, hass: HomeAssistant, config: ConfigType
|
||||||
) -> dict[str, vol.Schema]:
|
) -> dict[str, vol.Schema]:
|
||||||
@@ -52,38 +37,20 @@ class DeviceAutomationConditionProtocol(Protocol):
|
|||||||
"""List conditions."""
|
"""List conditions."""
|
||||||
|
|
||||||
|
|
||||||
class DeviceCondition(Condition):
|
async def async_validate_condition_config(
|
||||||
"""Device condition."""
|
hass: HomeAssistant, config: ConfigType
|
||||||
|
) -> ConfigType:
|
||||||
def __init__(self, hass: HomeAssistant, config: ConfigType) -> None:
|
"""Validate device condition config."""
|
||||||
"""Initialize condition."""
|
return await async_validate_device_automation_config(
|
||||||
self._config = config
|
hass, config, cv.DEVICE_CONDITION_SCHEMA, DeviceAutomationType.CONDITION
|
||||||
self._hass = hass
|
)
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def async_validate_condition_config(
|
|
||||||
cls, hass: HomeAssistant, config: ConfigType
|
|
||||||
) -> ConfigType:
|
|
||||||
"""Validate device condition config."""
|
|
||||||
return await async_validate_device_automation_config(
|
|
||||||
hass, config, cv.DEVICE_CONDITION_SCHEMA, DeviceAutomationType.CONDITION
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_condition_from_config(self) -> condition.ConditionCheckerType:
|
|
||||||
"""Test a device condition."""
|
|
||||||
platform = await async_get_device_automation_platform(
|
|
||||||
self._hass, self._config[CONF_DOMAIN], DeviceAutomationType.CONDITION
|
|
||||||
)
|
|
||||||
return trace_condition_function(
|
|
||||||
platform.async_condition_from_config(self._hass, self._config)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
CONDITIONS: dict[str, type[Condition]] = {
|
async def async_condition_from_config(
|
||||||
"device": DeviceCondition,
|
hass: HomeAssistant, config: ConfigType
|
||||||
}
|
) -> condition.ConditionCheckerType:
|
||||||
|
"""Test a device condition."""
|
||||||
|
platform = await async_get_device_automation_platform(
|
||||||
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
|
hass, config[CONF_DOMAIN], DeviceAutomationType.CONDITION
|
||||||
"""Return the sun conditions."""
|
)
|
||||||
return CONDITIONS
|
return trace_condition_function(platform.async_condition_from_config(hass, config))
|
||||||
|
@@ -18,7 +18,7 @@ from homeassistant.core import Event, HomeAssistant
|
|||||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||||
from homeassistant.helpers.device_registry import DeviceEntry
|
from homeassistant.helpers.device_registry import DeviceEntry
|
||||||
|
|
||||||
from .const import DOMAIN, PLATFORMS
|
from .const import GATEWAY_SERIAL_PATTERN, PLATFORMS
|
||||||
|
|
||||||
type DevoloHomeControlConfigEntry = ConfigEntry[list[HomeControl]]
|
type DevoloHomeControlConfigEntry = ConfigEntry[list[HomeControl]]
|
||||||
|
|
||||||
@@ -29,9 +29,19 @@ async def async_setup_entry(
|
|||||||
"""Set up the devolo account from a config entry."""
|
"""Set up the devolo account from a config entry."""
|
||||||
mydevolo = configure_mydevolo(entry.data)
|
mydevolo = configure_mydevolo(entry.data)
|
||||||
|
|
||||||
gateway_ids = await hass.async_add_executor_job(
|
credentials_valid = await hass.async_add_executor_job(mydevolo.credentials_valid)
|
||||||
check_mydevolo_and_get_gateway_ids, mydevolo
|
|
||||||
)
|
if not credentials_valid:
|
||||||
|
raise ConfigEntryAuthFailed
|
||||||
|
|
||||||
|
if await hass.async_add_executor_job(mydevolo.maintenance):
|
||||||
|
raise ConfigEntryNotReady
|
||||||
|
|
||||||
|
gateway_ids = await hass.async_add_executor_job(mydevolo.get_gateway_ids)
|
||||||
|
|
||||||
|
if entry.unique_id and GATEWAY_SERIAL_PATTERN.match(entry.unique_id):
|
||||||
|
uuid = await hass.async_add_executor_job(mydevolo.uuid)
|
||||||
|
hass.config_entries.async_update_entry(entry, unique_id=uuid)
|
||||||
|
|
||||||
def shutdown(event: Event) -> None:
|
def shutdown(event: Event) -> None:
|
||||||
for gateway in entry.runtime_data:
|
for gateway in entry.runtime_data:
|
||||||
@@ -59,11 +69,7 @@ async def async_setup_entry(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
except GatewayOfflineError as err:
|
except GatewayOfflineError as err:
|
||||||
raise ConfigEntryNotReady(
|
raise ConfigEntryNotReady from err
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="connection_failed",
|
|
||||||
translation_placeholders={"gateway_id": gateway_id},
|
|
||||||
) from err
|
|
||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
@@ -85,9 +91,7 @@ async def async_unload_entry(
|
|||||||
|
|
||||||
|
|
||||||
async def async_remove_config_entry_device(
|
async def async_remove_config_entry_device(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry
|
||||||
config_entry: DevoloHomeControlConfigEntry,
|
|
||||||
device_entry: DeviceEntry,
|
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Remove a config entry from a device."""
|
"""Remove a config entry from a device."""
|
||||||
return True
|
return True
|
||||||
@@ -99,19 +103,3 @@ def configure_mydevolo(conf: Mapping[str, Any]) -> Mydevolo:
|
|||||||
mydevolo.user = conf[CONF_USERNAME]
|
mydevolo.user = conf[CONF_USERNAME]
|
||||||
mydevolo.password = conf[CONF_PASSWORD]
|
mydevolo.password = conf[CONF_PASSWORD]
|
||||||
return mydevolo
|
return mydevolo
|
||||||
|
|
||||||
|
|
||||||
def check_mydevolo_and_get_gateway_ids(mydevolo: Mydevolo) -> list[str]:
|
|
||||||
"""Check if the credentials are valid and return user's gateway IDs as long as mydevolo is not in maintenance mode."""
|
|
||||||
if not mydevolo.credentials_valid():
|
|
||||||
raise ConfigEntryAuthFailed(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="invalid_auth",
|
|
||||||
)
|
|
||||||
if mydevolo.maintenance():
|
|
||||||
raise ConfigEntryNotReady(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="maintenance",
|
|
||||||
)
|
|
||||||
|
|
||||||
return mydevolo.get_gateway_ids()
|
|
||||||
|
@@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
from . import DevoloHomeControlConfigEntry
|
from . import DevoloHomeControlConfigEntry
|
||||||
from .entity import DevoloMultiLevelSwitchDeviceEntity
|
from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
|
@@ -1,5 +1,7 @@
|
|||||||
"""Constants for the devolo_home_control integration."""
|
"""Constants for the devolo_home_control integration."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
|
|
||||||
DOMAIN = "devolo_home_control"
|
DOMAIN = "devolo_home_control"
|
||||||
@@ -12,4 +14,5 @@ PLATFORMS = [
|
|||||||
Platform.SIREN,
|
Platform.SIREN,
|
||||||
Platform.SWITCH,
|
Platform.SWITCH,
|
||||||
]
|
]
|
||||||
|
GATEWAY_SERIAL_PATTERN = re.compile(r"\d{16}")
|
||||||
SUPPORTED_MODEL_TYPES = ["2600", "2601"]
|
SUPPORTED_MODEL_TYPES = ["2600", "2601"]
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user