mirror of
https://github.com/home-assistant/core.git
synced 2025-10-20 17:19:52 +00:00
Compare commits
8 Commits
cdce8p-bui
...
dev_target
Author | SHA1 | Date | |
---|---|---|---|
![]() |
6f6b2f1ad3 | ||
![]() |
1cc4890f75 | ||
![]() |
d3dd9b26c9 | ||
![]() |
a64d61df05 | ||
![]() |
e7c6c5311d | ||
![]() |
72a524c868 | ||
![]() |
b437113f31 | ||
![]() |
e0e263d3b5 |
2
.github/workflows/builder.yml
vendored
2
.github/workflows/builder.yml
vendored
@@ -326,7 +326,7 @@ jobs:
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
|
||||
with:
|
||||
cosign-release: "v2.2.3"
|
||||
|
||||
|
22
.github/workflows/ci.yaml
vendored
22
.github/workflows/ci.yaml
vendored
@@ -42,7 +42,7 @@ env:
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2025.11"
|
||||
DEFAULT_PYTHON: "3.13"
|
||||
ALL_PYTHON_VERSIONS: "['3.13', '3.14']"
|
||||
ALL_PYTHON_VERSIONS: "['3.13']"
|
||||
# 10.3 is the oldest supported version
|
||||
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
|
||||
# 10.6 is the current long-term-support
|
||||
@@ -428,7 +428,7 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: &matrix-python ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||
python-version: ${{ fromJSON(needs.info.outputs.python_versions) }}
|
||||
steps:
|
||||
- *checkout
|
||||
- &setup-python-matrix
|
||||
@@ -514,7 +514,9 @@ jobs:
|
||||
if: steps.cache-apt-check.outputs.cache-hit != 'true'
|
||||
uses: &actions-cache-save actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: *path-apt-cache
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
key: *key-apt-cache
|
||||
- name: Create Python virtual environment
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
@@ -639,7 +641,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: *matrix-python
|
||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||
steps:
|
||||
- *checkout
|
||||
- *setup-python-matrix
|
||||
@@ -836,8 +838,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: *matrix-python
|
||||
group: &matrix-group ${{ fromJson(needs.info.outputs.test_groups) }}
|
||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||
group: ${{ fromJson(needs.info.outputs.test_groups) }}
|
||||
steps:
|
||||
- *cache-restore-apt
|
||||
- name: Install additional OS dependencies
|
||||
@@ -962,7 +964,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: *matrix-python
|
||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||
mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }}
|
||||
steps:
|
||||
- *cache-restore-apt
|
||||
@@ -1079,7 +1081,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: *matrix-python
|
||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||
postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }}
|
||||
steps:
|
||||
- *cache-restore-apt
|
||||
@@ -1216,8 +1218,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: *matrix-python
|
||||
group: *matrix-group
|
||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||
group: ${{ fromJson(needs.info.outputs.test_groups) }}
|
||||
steps:
|
||||
- *cache-restore-apt
|
||||
- name: Install additional OS dependencies
|
||||
|
102
.github/workflows/wheels.yml
vendored
102
.github/workflows/wheels.yml
vendored
@@ -31,8 +31,7 @@ jobs:
|
||||
outputs:
|
||||
architectures: ${{ steps.info.outputs.architectures }}
|
||||
steps:
|
||||
- &checkout
|
||||
name: Checkout the repository
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
@@ -80,8 +79,6 @@ jobs:
|
||||
|
||||
# Add additional pip wheel build constraints
|
||||
echo "PIP_CONSTRAINT=build_constraints.txt"
|
||||
|
||||
echo 'CFLAGS="-Wno-error=int-conversion"'
|
||||
) > .env_file
|
||||
|
||||
- name: Write pip wheel build constraints
|
||||
@@ -94,7 +91,7 @@ jobs:
|
||||
) > build_constraints.txt
|
||||
|
||||
- name: Upload env_file
|
||||
uses: &actions-upload-artifact actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: env_file
|
||||
path: ./.env_file
|
||||
@@ -102,14 +99,14 @@ jobs:
|
||||
overwrite: true
|
||||
|
||||
- name: Upload build_constraints
|
||||
uses: *actions-upload-artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: build_constraints
|
||||
path: ./build_constraints.txt
|
||||
overwrite: true
|
||||
|
||||
- name: Upload requirements_diff
|
||||
uses: *actions-upload-artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: requirements_diff
|
||||
path: ./requirements_diff.txt
|
||||
@@ -121,50 +118,37 @@ jobs:
|
||||
python -m script.gen_requirements_all ci
|
||||
|
||||
- name: Upload requirements_all_wheels
|
||||
uses: *actions-upload-artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: requirements_all_wheels
|
||||
path: ./requirements_all_wheels_*.txt
|
||||
|
||||
core:
|
||||
name: Build Core wheels ${{ matrix.abi }} for ${{ matrix.arch }} (musllinux_1_2)
|
||||
if: false && github.repository_owner == 'home-assistant'
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
needs: init
|
||||
runs-on: ${{ matrix.os }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: &matrix-build
|
||||
abi: ["cp314"]
|
||||
matrix:
|
||||
abi: ["cp313"]
|
||||
arch: ${{ fromJson(needs.init.outputs.architectures) }}
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
- arch: aarch64
|
||||
os: ubuntu-24.04-arm
|
||||
exclude:
|
||||
- abi: cp314
|
||||
arch: armv7
|
||||
- abi: cp314
|
||||
arch: armhf
|
||||
- abi: cp314
|
||||
arch: i386
|
||||
steps:
|
||||
- *checkout
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- &download-env-file
|
||||
name: Download env_file
|
||||
uses: &actions-download-artifact actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
- name: Download env_file
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: env_file
|
||||
|
||||
- &download-build-constraints
|
||||
name: Download build_constraints
|
||||
uses: *actions-download-artifact
|
||||
- name: Download build_constraints
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: build_constraints
|
||||
|
||||
- &download-requirements-diff
|
||||
name: Download requirements_diff
|
||||
uses: *actions-download-artifact
|
||||
- name: Download requirements_diff
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: requirements_diff
|
||||
|
||||
@@ -176,7 +160,7 @@ jobs:
|
||||
|
||||
# home-assistant/wheels doesn't support sha pinning
|
||||
- name: Build wheels
|
||||
uses: &home-assistant-wheels home-assistant/wheels@2025.10.0
|
||||
uses: home-assistant/wheels@2025.09.1
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
@@ -193,19 +177,33 @@ jobs:
|
||||
name: Build wheels ${{ matrix.abi }} for ${{ matrix.arch }}
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
needs: init
|
||||
runs-on: ${{ matrix.os }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: *matrix-build
|
||||
matrix:
|
||||
abi: ["cp313"]
|
||||
arch: ${{ fromJson(needs.init.outputs.architectures) }}
|
||||
steps:
|
||||
- *checkout
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- *download-env-file
|
||||
- *download-build-constraints
|
||||
- *download-requirements-diff
|
||||
- name: Download env_file
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: env_file
|
||||
|
||||
- name: Download build_constraints
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: build_constraints
|
||||
|
||||
- name: Download requirements_diff
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: requirements_diff
|
||||
|
||||
- name: Download requirements_all_wheels
|
||||
uses: *actions-download-artifact
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: requirements_all_wheels
|
||||
|
||||
@@ -221,29 +219,9 @@ jobs:
|
||||
sed -i "/uv/d" requirements.txt
|
||||
sed -i "/uv/d" requirements_diff.txt
|
||||
|
||||
- name: Create requirements file for custom build
|
||||
run: |
|
||||
touch requirements_custom.txt
|
||||
echo "netifaces==0.11.0" >> requirements_custom.txt
|
||||
|
||||
- name: Build wheels (custom)
|
||||
uses: cdce8p/wheels@master
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
arch: ${{ matrix.arch }}
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||
env-file: true
|
||||
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev"
|
||||
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
|
||||
constraints: "homeassistant/package_constraints.txt"
|
||||
requirements: "requirements_custom.txt"
|
||||
verbose: true
|
||||
|
||||
# home-assistant/wheels doesn't support sha pinning
|
||||
- name: Build wheels
|
||||
uses: *home-assistant-wheels
|
||||
if: false
|
||||
uses: home-assistant/wheels@2025.09.1
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
|
@@ -182,6 +182,7 @@ homeassistant.components.efergy.*
|
||||
homeassistant.components.eheimdigital.*
|
||||
homeassistant.components.electrasmart.*
|
||||
homeassistant.components.electric_kiwi.*
|
||||
homeassistant.components.elevenlabs.*
|
||||
homeassistant.components.elgato.*
|
||||
homeassistant.components.elkm1.*
|
||||
homeassistant.components.emulated_hue.*
|
||||
|
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -619,8 +619,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/greeneye_monitor/ @jkeljo
|
||||
/homeassistant/components/group/ @home-assistant/core
|
||||
/tests/components/group/ @home-assistant/core
|
||||
/homeassistant/components/growatt_server/ @johanzander
|
||||
/tests/components/growatt_server/ @johanzander
|
||||
/homeassistant/components/guardian/ @bachya
|
||||
/tests/components/guardian/ @bachya
|
||||
/homeassistant/components/habitica/ @tr4nt0r
|
||||
|
10
build.yaml
10
build.yaml
@@ -1,10 +1,10 @@
|
||||
image: ghcr.io/home-assistant/{arch}-homeassistant
|
||||
build_from:
|
||||
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.10.1
|
||||
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.10.1
|
||||
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.10.1
|
||||
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.10.1
|
||||
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.10.1
|
||||
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.10.0
|
||||
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.10.0
|
||||
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.10.0
|
||||
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.10.0
|
||||
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.10.0
|
||||
codenotary:
|
||||
signer: notary@home-assistant.io
|
||||
base_image: notary@home-assistant.io
|
||||
|
@@ -34,9 +34,6 @@ INPUT_FIELD_CODE = "code"
|
||||
|
||||
DUMMY_SECRET = "FPPTH34D4E3MI2HG"
|
||||
|
||||
GOOGLE_AUTHENTICATOR_URL = "https://support.google.com/accounts/answer/1066447"
|
||||
AUTHY_URL = "https://authy.com/"
|
||||
|
||||
|
||||
def _generate_qr_code(data: str) -> str:
|
||||
"""Generate a base64 PNG string represent QR Code image of data."""
|
||||
@@ -232,8 +229,6 @@ class TotpSetupFlow(SetupFlow[TotpAuthModule]):
|
||||
"code": self._ota_secret,
|
||||
"url": self._url,
|
||||
"qr_code": self._image,
|
||||
"google_authenticator_url": GOOGLE_AUTHENTICATOR_URL,
|
||||
"authy_url": AUTHY_URL,
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/adax",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["adax", "adax_local"],
|
||||
"requirements": ["adax==0.4.0", "Adax-local==0.2.0"]
|
||||
"requirements": ["adax==0.4.0", "Adax-local==0.1.5"]
|
||||
}
|
||||
|
@@ -53,6 +53,9 @@ __all__ = [
|
||||
"GenImageTaskResult",
|
||||
"async_generate_data",
|
||||
"async_generate_image",
|
||||
"async_setup",
|
||||
"async_setup_entry",
|
||||
"async_unload_entry",
|
||||
]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
@@ -26,10 +26,6 @@ from .const import DOMAIN
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Documentation URL for API key generation
|
||||
_API_KEY_URL = "https://docs.airnowapi.org/account/request/"
|
||||
|
||||
|
||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> bool:
|
||||
"""Validate the user input allows us to connect.
|
||||
|
||||
@@ -118,7 +114,6 @@ class AirNowConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
),
|
||||
}
|
||||
),
|
||||
description_placeholders={"api_key_url": _API_KEY_URL},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
@@ -2,7 +2,7 @@
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "To generate API key go to {api_key_url}",
|
||||
"description": "To generate API key go to https://docs.airnowapi.org/account/request/",
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"latitude": "[%key:common::config_flow::data::latitude%]",
|
||||
|
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"data_description": {
|
||||
"return_average": "air-Q allows to poll both the noisy sensor readings as well as the values averaged on the device (default)",
|
||||
"clip_negatives": "For baseline calibration purposes, certain sensor values may briefly become negative. The default behavior is to clip such values to 0"
|
||||
"clip_negatives": "For baseline calibration purposes, certain sensor values may briefly become negative. The default behaviour is to clip such values to 0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -41,11 +41,6 @@ APPS_NEW_ID = "add_new"
|
||||
CONF_APP_DELETE = "app_delete"
|
||||
CONF_APP_ID = "app_id"
|
||||
|
||||
_EXAMPLE_APP_ID = "com.plexapp.android"
|
||||
_EXAMPLE_APP_PLAY_STORE_URL = (
|
||||
f"https://play.google.com/store/apps/details?id={_EXAMPLE_APP_ID}"
|
||||
)
|
||||
|
||||
STEP_PAIR_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("pin"): str,
|
||||
@@ -360,7 +355,5 @@ class AndroidTVRemoteOptionsFlowHandler(OptionsFlowWithReload):
|
||||
data_schema=data_schema,
|
||||
description_placeholders={
|
||||
"app_id": f"`{app_id}`" if app_id != APPS_NEW_ID else "",
|
||||
"example_app_id": _EXAMPLE_APP_ID,
|
||||
"example_app_play_store_url": _EXAMPLE_APP_PLAY_STORE_URL,
|
||||
},
|
||||
)
|
||||
|
@@ -75,7 +75,7 @@
|
||||
},
|
||||
"data_description": {
|
||||
"app_name": "Name of the application as you would like it to be displayed in Home Assistant.",
|
||||
"app_id": "E.g. {example_app_id} for {example_app_play_store_url}",
|
||||
"app_id": "E.g. com.plexapp.android for https://play.google.com/store/apps/details?id=com.plexapp.android",
|
||||
"app_icon": "Image URL. From the Play Store app page, right click on the icon and select 'Copy image address' and then paste it here. Alternatively, download the image, upload it under /config/www/ and use the URL /local/filename",
|
||||
"app_delete": "Check this box to delete the application from the list."
|
||||
}
|
||||
|
@@ -5,9 +5,14 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
from random import randrange
|
||||
import sys
|
||||
from typing import Any, cast
|
||||
|
||||
from pyatv import connect, exceptions, scan
|
||||
from pyatv.conf import AppleTV
|
||||
from pyatv.const import DeviceModel, Protocol
|
||||
from pyatv.convert import model_str
|
||||
from pyatv.interface import AppleTV as AppleTVInterface, DeviceListener
|
||||
|
||||
from homeassistant.components import zeroconf
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
@@ -24,11 +29,7 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryNotReady,
|
||||
HomeAssistantError,
|
||||
)
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
@@ -42,18 +43,6 @@ from .const import (
|
||||
SIGNAL_DISCONNECTED,
|
||||
)
|
||||
|
||||
if sys.version_info < (3, 14):
|
||||
from pyatv import connect, exceptions, scan
|
||||
from pyatv.conf import AppleTV
|
||||
from pyatv.const import DeviceModel, Protocol
|
||||
from pyatv.convert import model_str
|
||||
from pyatv.interface import AppleTV as AppleTVInterface, DeviceListener
|
||||
else:
|
||||
|
||||
class DeviceListener:
|
||||
"""Dummy class."""
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_NAME_TV = "Apple TV"
|
||||
@@ -64,41 +53,31 @@ BACKOFF_TIME_UPPER_LIMIT = 300 # Five minutes
|
||||
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE]
|
||||
|
||||
if sys.version_info < (3, 14):
|
||||
AUTH_EXCEPTIONS = (
|
||||
exceptions.AuthenticationError,
|
||||
exceptions.InvalidCredentialsError,
|
||||
exceptions.NoCredentialsError,
|
||||
)
|
||||
CONNECTION_TIMEOUT_EXCEPTIONS = (
|
||||
OSError,
|
||||
asyncio.CancelledError,
|
||||
TimeoutError,
|
||||
exceptions.ConnectionLostError,
|
||||
exceptions.ConnectionFailedError,
|
||||
)
|
||||
DEVICE_EXCEPTIONS = (
|
||||
exceptions.ProtocolError,
|
||||
exceptions.NoServiceError,
|
||||
exceptions.PairingError,
|
||||
exceptions.BackOffError,
|
||||
exceptions.DeviceIdMissingError,
|
||||
)
|
||||
else:
|
||||
AUTH_EXCEPTIONS = ()
|
||||
CONNECTION_TIMEOUT_EXCEPTIONS = ()
|
||||
DEVICE_EXCEPTIONS = ()
|
||||
|
||||
AUTH_EXCEPTIONS = (
|
||||
exceptions.AuthenticationError,
|
||||
exceptions.InvalidCredentialsError,
|
||||
exceptions.NoCredentialsError,
|
||||
)
|
||||
CONNECTION_TIMEOUT_EXCEPTIONS = (
|
||||
OSError,
|
||||
asyncio.CancelledError,
|
||||
TimeoutError,
|
||||
exceptions.ConnectionLostError,
|
||||
exceptions.ConnectionFailedError,
|
||||
)
|
||||
DEVICE_EXCEPTIONS = (
|
||||
exceptions.ProtocolError,
|
||||
exceptions.NoServiceError,
|
||||
exceptions.PairingError,
|
||||
exceptions.BackOffError,
|
||||
exceptions.DeviceIdMissingError,
|
||||
)
|
||||
|
||||
type AppleTvConfigEntry = ConfigEntry[AppleTVManager]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AppleTvConfigEntry) -> bool:
|
||||
"""Set up a config entry for Apple TV."""
|
||||
if sys.version_info >= (3, 14):
|
||||
raise HomeAssistantError(
|
||||
"Apple TV is not supported on Python 3.14. Please use Python 3.13."
|
||||
)
|
||||
manager = AppleTVManager(hass, entry)
|
||||
|
||||
if manager.is_on:
|
||||
|
@@ -7,7 +7,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/apple_tv",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyatv", "srptools"],
|
||||
"requirements": ["pyatv==0.16.1;python_version<'3.14'"],
|
||||
"requirements": ["pyatv==0.16.1"],
|
||||
"zeroconf": [
|
||||
"_mediaremotetv._tcp.local.",
|
||||
"_companion-link._tcp.local.",
|
||||
|
@@ -41,8 +41,6 @@ from .pipeline import (
|
||||
async_setup_pipeline_store,
|
||||
async_update_pipeline,
|
||||
)
|
||||
from .select import AssistPipelineSelect, VadSensitivitySelect
|
||||
from .vad import VadSensitivity
|
||||
from .websocket_api import async_register_websocket_api
|
||||
|
||||
__all__ = (
|
||||
@@ -53,18 +51,16 @@ __all__ = (
|
||||
"SAMPLE_CHANNELS",
|
||||
"SAMPLE_RATE",
|
||||
"SAMPLE_WIDTH",
|
||||
"AssistPipelineSelect",
|
||||
"AudioSettings",
|
||||
"Pipeline",
|
||||
"PipelineEvent",
|
||||
"PipelineEventType",
|
||||
"PipelineNotFound",
|
||||
"VadSensitivity",
|
||||
"VadSensitivitySelect",
|
||||
"WakeWordSettings",
|
||||
"async_create_default_pipeline",
|
||||
"async_get_pipelines",
|
||||
"async_pipeline_from_audio_stream",
|
||||
"async_setup",
|
||||
"async_update_pipeline",
|
||||
)
|
||||
|
||||
|
@@ -19,14 +19,7 @@ import wave
|
||||
import hass_nabucasa
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import (
|
||||
conversation,
|
||||
media_player,
|
||||
stt,
|
||||
tts,
|
||||
wake_word,
|
||||
websocket_api,
|
||||
)
|
||||
from homeassistant.components import conversation, stt, tts, wake_word, websocket_api
|
||||
from homeassistant.const import ATTR_SUPPORTED_FEATURES, MATCH_ALL
|
||||
from homeassistant.core import Context, HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -137,10 +130,7 @@ SAVE_DELAY = 10
|
||||
@callback
|
||||
def _async_local_fallback_intent_filter(result: RecognizeResult) -> bool:
|
||||
"""Filter out intents that are not local fallback."""
|
||||
return result.intent.name in (
|
||||
intent.INTENT_GET_STATE,
|
||||
media_player.INTENT_MEDIA_SEARCH_AND_PLAY,
|
||||
)
|
||||
return result.intent.name in (intent.INTENT_GET_STATE)
|
||||
|
||||
|
||||
@callback
|
||||
|
@@ -12,8 +12,7 @@ from aioasuswrt.asuswrt import AsusWrt as AsusWrtLegacy
|
||||
from aiohttp import ClientSession
|
||||
from asusrouter import AsusRouter, AsusRouterError
|
||||
from asusrouter.config import ARConfigKey
|
||||
from asusrouter.modules.client import AsusClient
|
||||
from asusrouter.modules.connection import ConnectionState
|
||||
from asusrouter.modules.client import AsusClient, ConnectionState
|
||||
from asusrouter.modules.data import AsusData
|
||||
from asusrouter.modules.homeassistant import convert_to_ha_data, convert_to_ha_sensors
|
||||
from asusrouter.tools.connection import get_cookie_jar
|
||||
@@ -72,16 +71,7 @@ class WrtDevice(NamedTuple):
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type _FuncType[_T] = Callable[
|
||||
[_T],
|
||||
Awaitable[
|
||||
list[str]
|
||||
| tuple[float | None, float | None]
|
||||
| list[float]
|
||||
| dict[str, float | str | None]
|
||||
| dict[str, float]
|
||||
],
|
||||
]
|
||||
type _FuncType[_T] = Callable[[_T], Awaitable[list[Any] | tuple[Any] | dict[str, Any]]]
|
||||
type _ReturnFuncType[_T] = Callable[[_T], Coroutine[Any, Any, dict[str, Any]]]
|
||||
|
||||
|
||||
@@ -96,9 +86,7 @@ def handle_errors_and_zip[_AsusWrtBridgeT: AsusWrtBridge](
|
||||
"""Run library methods and zip results or manage exceptions."""
|
||||
|
||||
@functools.wraps(func)
|
||||
async def _wrapper(
|
||||
self: _AsusWrtBridgeT,
|
||||
) -> dict[str, float | str | None] | dict[str, float]:
|
||||
async def _wrapper(self: _AsusWrtBridgeT) -> dict[str, Any]:
|
||||
try:
|
||||
data = await func(self)
|
||||
except exceptions as exc:
|
||||
@@ -125,9 +113,7 @@ class AsusWrtBridge(ABC):
|
||||
|
||||
@staticmethod
|
||||
def get_bridge(
|
||||
hass: HomeAssistant,
|
||||
conf: dict[str, str | int],
|
||||
options: dict[str, str | bool | int] | None = None,
|
||||
hass: HomeAssistant, conf: dict[str, Any], options: dict[str, Any] | None = None
|
||||
) -> AsusWrtBridge:
|
||||
"""Get Bridge instance."""
|
||||
if conf[CONF_PROTOCOL] in (PROTOCOL_HTTPS, PROTOCOL_HTTP):
|
||||
@@ -326,22 +312,22 @@ class AsusWrtLegacyBridge(AsusWrtBridge):
|
||||
return [SENSORS_TEMPERATURES_LEGACY[i] for i in range(3) if availability[i]]
|
||||
|
||||
@handle_errors_and_zip((IndexError, OSError, ValueError), SENSORS_BYTES)
|
||||
async def _get_bytes(self) -> tuple[float | None, float | None]:
|
||||
async def _get_bytes(self) -> Any:
|
||||
"""Fetch byte information from the router."""
|
||||
return await self._api.async_get_bytes_total()
|
||||
|
||||
@handle_errors_and_zip((IndexError, OSError, ValueError), SENSORS_RATES)
|
||||
async def _get_rates(self) -> tuple[float, float]:
|
||||
async def _get_rates(self) -> Any:
|
||||
"""Fetch rates information from the router."""
|
||||
return await self._api.async_get_current_transfer_rates()
|
||||
|
||||
@handle_errors_and_zip((IndexError, OSError, ValueError), SENSORS_LOAD_AVG)
|
||||
async def _get_load_avg(self) -> list[float]:
|
||||
async def _get_load_avg(self) -> Any:
|
||||
"""Fetch load average information from the router."""
|
||||
return await self._api.async_get_loadavg()
|
||||
|
||||
@handle_errors_and_zip((OSError, ValueError), None)
|
||||
async def _get_temperatures(self) -> dict[str, float]:
|
||||
async def _get_temperatures(self) -> Any:
|
||||
"""Fetch temperatures information from the router."""
|
||||
return await self._api.async_get_temperature()
|
||||
|
||||
|
@@ -175,12 +175,12 @@ class AsusWrtFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
|
||||
async def _async_check_connection(
|
||||
self, user_input: dict[str, str | int]
|
||||
self, user_input: dict[str, Any]
|
||||
) -> tuple[str, str | None]:
|
||||
"""Attempt to connect the AsusWrt router."""
|
||||
|
||||
api: AsusWrtBridge
|
||||
host = user_input[CONF_HOST]
|
||||
host: str = user_input[CONF_HOST]
|
||||
protocol = user_input[CONF_PROTOCOL]
|
||||
error: str | None = None
|
||||
|
||||
|
@@ -176,7 +176,7 @@ class AsusWrtRouter:
|
||||
|
||||
self._on_close: list[Callable] = []
|
||||
|
||||
self._options: dict[str, str | bool | int] = {
|
||||
self._options: dict[str, Any] = {
|
||||
CONF_DNSMASQ: DEFAULT_DNSMASQ,
|
||||
CONF_INTERFACE: DEFAULT_INTERFACE,
|
||||
CONF_REQUIRE_IP: True,
|
||||
@@ -299,10 +299,12 @@ class AsusWrtRouter:
|
||||
_LOGGER.warning("Reconnected to ASUS router %s", self.host)
|
||||
|
||||
self._connected_devices = len(wrt_devices)
|
||||
consider_home = int(
|
||||
self._options.get(CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.total_seconds())
|
||||
consider_home: int = self._options.get(
|
||||
CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.total_seconds()
|
||||
)
|
||||
track_unknown: bool = self._options.get(
|
||||
CONF_TRACK_UNKNOWN, DEFAULT_TRACK_UNKNOWN
|
||||
)
|
||||
track_unknown = self._options.get(CONF_TRACK_UNKNOWN, DEFAULT_TRACK_UNKNOWN)
|
||||
|
||||
for device_mac, device in self._devices.items():
|
||||
dev_info = wrt_devices.pop(device_mac, None)
|
||||
|
@@ -5,7 +5,7 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Set up two-factor authentication using TOTP",
|
||||
"description": "To activate two-factor authentication using time-based one-time passwords, scan the QR code with your authentication app. If you don't have one, we recommend either [Google Authenticator]({google_authenticator_url}) or [Authy]({authy_url}).\n\n{qr_code}\n\nAfter scanning the code, enter the six-digit code from your app to verify the setup. If you have problems scanning the QR code, do a manual setup with code **`{code}`**."
|
||||
"description": "To activate two-factor authentication using time-based one-time passwords, scan the QR code with your authentication app. If you don't have one, we recommend either [Google Authenticator](https://support.google.com/accounts/answer/1066447) or [Authy](https://authy.com/).\n\n{qr_code}\n\nAfter scanning the code, enter the six-digit code from your app to verify the setup. If you have problems scanning the QR code, do a manual setup with code **`{code}`**."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
@@ -146,7 +146,7 @@
|
||||
},
|
||||
"state": {
|
||||
"title": "Add a Bayesian sensor",
|
||||
"description": "Add an observation which evaluates to `True` when the value of the sensor exactly matches *'To state'*. When `False`, it will update the prior with probabilities that are the inverse of those set below. This behavior can be overridden by adding observations for the same entity's other states.",
|
||||
"description": "Add an observation which evaluates to `True` when the value of the sensor exactly matches *'To state'*. When `False`, it will update the prior with probabilities that are the inverse of those set below. This behaviour can be overridden by adding observations for the same entity's other states.",
|
||||
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
|
@@ -113,6 +113,7 @@ __all__ = [
|
||||
"BluetoothServiceInfo",
|
||||
"BluetoothServiceInfoBleak",
|
||||
"HaBluetoothConnector",
|
||||
"HomeAssistantRemoteScanner",
|
||||
"async_address_present",
|
||||
"async_ble_device_from_address",
|
||||
"async_clear_address_from_match_history",
|
||||
|
@@ -3,20 +3,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from functools import partial
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import caldav
|
||||
from caldav.lib.error import DAVError
|
||||
import requests
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.calendar import (
|
||||
ENTITY_ID_FORMAT,
|
||||
PLATFORM_SCHEMA as CALENDAR_PLATFORM_SCHEMA,
|
||||
CalendarEntity,
|
||||
CalendarEntityFeature,
|
||||
CalendarEvent,
|
||||
is_offset_reached,
|
||||
)
|
||||
@@ -28,7 +23,6 @@ from homeassistant.const import (
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity import async_generate_entity_id
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
@@ -181,8 +175,6 @@ async def async_setup_entry(
|
||||
class WebDavCalendarEntity(CoordinatorEntity[CalDavUpdateCoordinator], CalendarEntity):
|
||||
"""A device for getting the next Task from a WebDav Calendar."""
|
||||
|
||||
_attr_supported_features = CalendarEntityFeature.CREATE_EVENT
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str | None,
|
||||
@@ -211,31 +203,6 @@ class WebDavCalendarEntity(CoordinatorEntity[CalDavUpdateCoordinator], CalendarE
|
||||
"""Get all events in a specific time frame."""
|
||||
return await self.coordinator.async_get_events(hass, start_date, end_date)
|
||||
|
||||
async def async_create_event(self, **kwargs: Any) -> None:
|
||||
"""Create a new event in the calendar."""
|
||||
_LOGGER.debug("Event: %s", kwargs)
|
||||
|
||||
item_data: dict[str, Any] = {
|
||||
"summary": kwargs["summary"],
|
||||
"dtstart": kwargs["dtstart"],
|
||||
"dtend": kwargs["dtend"],
|
||||
}
|
||||
if description := kwargs.get("description"):
|
||||
item_data["description"] = description
|
||||
if location := kwargs.get("location"):
|
||||
item_data["location"] = location
|
||||
if rrule := kwargs.get("rrule"):
|
||||
item_data["rrule"] = rrule
|
||||
|
||||
_LOGGER.debug("ICS data %s", item_data)
|
||||
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
partial(self.coordinator.calendar.add_event, **item_data),
|
||||
)
|
||||
except (requests.ConnectionError, DAVError) as err:
|
||||
raise HomeAssistantError(f"CalDAV save error: {err}") from err
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Update event data."""
|
||||
|
@@ -74,10 +74,7 @@ from .const import (
|
||||
StreamType,
|
||||
)
|
||||
from .helper import get_camera_from_entity_id
|
||||
from .img_util import (
|
||||
TurboJPEGSingleton, # noqa: F401
|
||||
scale_jpeg_camera_image,
|
||||
)
|
||||
from .img_util import scale_jpeg_camera_image
|
||||
from .prefs import (
|
||||
CameraPreferences,
|
||||
DynamicStreamSettings, # noqa: F401
|
||||
|
@@ -19,7 +19,7 @@ from homeassistant.components.alexa import (
|
||||
errors as alexa_errors,
|
||||
smart_home as alexa_smart_home,
|
||||
)
|
||||
from homeassistant.components.camera import async_register_ice_servers
|
||||
from homeassistant.components.camera.webrtc import async_register_ice_servers
|
||||
from homeassistant.components.google_assistant import smart_home as ga
|
||||
from homeassistant.const import __version__ as HA_VERSION
|
||||
from homeassistant.core import Context, HassJob, HomeAssistant, callback
|
||||
|
@@ -12,9 +12,7 @@ from hass_nabucasa.google_report_state import ErrorResponse
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
|
||||
from homeassistant.components.google_assistant import DOMAIN as GOOGLE_DOMAIN
|
||||
from homeassistant.components.google_assistant.helpers import ( # pylint: disable=hass-component-root-import
|
||||
AbstractConfig,
|
||||
)
|
||||
from homeassistant.components.google_assistant.helpers import AbstractConfig
|
||||
from homeassistant.components.homeassistant.exposed_entities import (
|
||||
async_expose_entity,
|
||||
async_get_assistant_settings,
|
||||
|
@@ -13,6 +13,6 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["acme", "hass_nabucasa", "snitun"],
|
||||
"requirements": ["hass-nabucasa==1.4.0"],
|
||||
"requirements": ["hass-nabucasa==1.3.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
@@ -11,7 +11,7 @@ from hass_nabucasa.voice import MAP_VOICE, Gender
|
||||
from homeassistant.auth.const import GROUP_ID_ADMIN
|
||||
from homeassistant.auth.models import User
|
||||
from homeassistant.components import webhook
|
||||
from homeassistant.components.google_assistant.http import ( # pylint: disable=hass-component-root-import
|
||||
from homeassistant.components.google_assistant.http import (
|
||||
async_get_users as async_get_google_assistant_users,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
@@ -78,10 +78,7 @@ class CompitConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
description_placeholders={"compit_url": "https://inext.compit.pl/"},
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_reauth(self, data: Mapping[str, Any]) -> ConfigFlowResult:
|
||||
|
@@ -2,7 +2,7 @@
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "Please enter your {compit_url} credentials.",
|
||||
"description": "Please enter your https://inext.compit.pl/ credentials.",
|
||||
"title": "Connect to Compit iNext",
|
||||
"data": {
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
|
@@ -6,9 +6,7 @@ from typing import Any
|
||||
import uuid
|
||||
|
||||
from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN
|
||||
from homeassistant.components.automation.config import ( # pylint: disable=hass-component-root-import
|
||||
async_validate_config_item,
|
||||
)
|
||||
from homeassistant.components.automation.config import async_validate_config_item
|
||||
from homeassistant.config import AUTOMATION_CONFIG_PATH
|
||||
from homeassistant.const import CONF_ID, SERVICE_RELOAD
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
@@ -5,9 +5,7 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN
|
||||
from homeassistant.components.script.config import ( # pylint: disable=hass-component-root-import
|
||||
async_validate_config_item,
|
||||
)
|
||||
from homeassistant.components.script.config import async_validate_config_item
|
||||
from homeassistant.config import SCRIPT_CONFIG_PATH
|
||||
from homeassistant.const import SERVICE_RELOAD
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
@@ -87,6 +87,7 @@ __all__ = [
|
||||
"async_get_chat_log",
|
||||
"async_get_result_from_chat_log",
|
||||
"async_set_agent",
|
||||
"async_setup",
|
||||
"async_unset_agent",
|
||||
]
|
||||
|
||||
|
@@ -569,17 +569,14 @@ class ChatLog:
|
||||
if llm_api:
|
||||
prompt_parts.append(llm_api.api_prompt)
|
||||
|
||||
# Append current date and time to the prompt if the corresponding tool is not provided
|
||||
llm_tools: list[llm.Tool] = llm_api.tools if llm_api else []
|
||||
if not any(tool.name.endswith("GetDateTime") for tool in llm_tools):
|
||||
prompt_parts.append(
|
||||
await self._async_expand_prompt_template(
|
||||
llm_context,
|
||||
llm.DATE_TIME_PROMPT,
|
||||
llm_context.language,
|
||||
user_name,
|
||||
)
|
||||
prompt_parts.append(
|
||||
await self._async_expand_prompt_template(
|
||||
llm_context,
|
||||
llm.BASE_PROMPT,
|
||||
llm_context.language,
|
||||
user_name,
|
||||
)
|
||||
)
|
||||
|
||||
if extra_system_prompt := (
|
||||
# Take new system prompt if one was given
|
||||
|
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -10,7 +9,7 @@ from pycync import Auth
|
||||
from pycync.exceptions import AuthFailedError, CyncError, TwoFactorRequiredError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
@@ -40,7 +39,7 @@ class CyncConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
VERSION = 1
|
||||
|
||||
cync_auth: Auth = None
|
||||
cync_auth: Auth
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -48,14 +47,29 @@ class CyncConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Attempt login with user credentials."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input:
|
||||
try:
|
||||
errors = await self._validate_credentials(user_input)
|
||||
except TwoFactorRequiredError:
|
||||
return await self.async_step_two_factor()
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
if not errors:
|
||||
return await self._create_config_entry(self.cync_auth.username)
|
||||
self.cync_auth = Auth(
|
||||
async_get_clientsession(self.hass),
|
||||
username=user_input[CONF_EMAIL],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
)
|
||||
try:
|
||||
await self.cync_auth.login()
|
||||
except AuthFailedError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except TwoFactorRequiredError:
|
||||
return await self.async_step_two_factor()
|
||||
except CyncError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return await self._create_config_entry(self.cync_auth.username)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
@@ -67,65 +81,12 @@ class CyncConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Attempt login with the two factor auth code sent to the user."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input:
|
||||
errors = await self._validate_credentials(user_input)
|
||||
|
||||
if not errors:
|
||||
return await self._create_config_entry(self.cync_auth.username)
|
||||
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
step_id="two_factor", data_schema=STEP_TWO_FACTOR_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="two_factor", data_schema=STEP_TWO_FACTOR_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Perform reauth upon an API authentication error."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Dialog that informs the user that reauth is required and prompts for their Cync credentials."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
|
||||
if user_input:
|
||||
try:
|
||||
errors = await self._validate_credentials(user_input)
|
||||
except TwoFactorRequiredError:
|
||||
return await self.async_step_two_factor()
|
||||
|
||||
if not errors:
|
||||
return await self._create_config_entry(self.cync_auth.username)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
description_placeholders={CONF_EMAIL: reauth_entry.title},
|
||||
)
|
||||
|
||||
async def _validate_credentials(self, user_input: dict[str, Any]) -> dict[str, str]:
|
||||
"""Attempt to log in with user email and password, and return the error dict."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if not self.cync_auth:
|
||||
self.cync_auth = Auth(
|
||||
async_get_clientsession(self.hass),
|
||||
username=user_input[CONF_EMAIL],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
)
|
||||
|
||||
try:
|
||||
await self.cync_auth.login(user_input.get(CONF_TWO_FACTOR_CODE))
|
||||
except TwoFactorRequiredError:
|
||||
raise
|
||||
await self.cync_auth.login(user_input[CONF_TWO_FACTOR_CODE])
|
||||
except AuthFailedError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except CyncError:
|
||||
@@ -133,29 +94,25 @@ class CyncConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return await self._create_config_entry(self.cync_auth.username)
|
||||
|
||||
return errors
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def _create_config_entry(self, user_email: str) -> ConfigFlowResult:
|
||||
"""Create the Cync config entry using input user data."""
|
||||
|
||||
cync_user = self.cync_auth.user
|
||||
await self.async_set_unique_id(str(cync_user.user_id))
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
config_data = {
|
||||
config = {
|
||||
CONF_USER_ID: cync_user.user_id,
|
||||
CONF_AUTHORIZE_STRING: cync_user.authorize,
|
||||
CONF_EXPIRES_AT: cync_user.expires_at,
|
||||
CONF_ACCESS_TOKEN: cync_user.access_token,
|
||||
CONF_REFRESH_TOKEN: cync_user.refresh_token,
|
||||
}
|
||||
|
||||
if self.source == SOURCE_REAUTH:
|
||||
self._abort_if_unique_id_mismatch()
|
||||
return self.async_update_reload_and_abort(
|
||||
entry=self._get_reauth_entry(), title=user_email, data=config_data
|
||||
)
|
||||
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(title=user_email, data=config_data)
|
||||
return self.async_create_entry(title=user_email, data=config)
|
||||
|
@@ -37,7 +37,7 @@ rules:
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
|
@@ -18,18 +18,6 @@
|
||||
"data_description": {
|
||||
"two_factor_code": "The two-factor code sent to your Cync account's email"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "[%key:common::config_flow::title::reauth%]",
|
||||
"description": "The Cync integration needs to re-authenticate for {email}",
|
||||
"data": {
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"email": "[%key:component::cync::config::step::user::data_description::email%]",
|
||||
"password": "[%key:component::cync::config::step::user::data_description::password%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -38,9 +26,7 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"unique_id_mismatch": "An incorrect user was provided by Cync for your email address, please consult your Cync app"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -5,9 +5,7 @@ from __future__ import annotations
|
||||
import datetime
|
||||
|
||||
from homeassistant.components.alarm_control_panel import AlarmControlPanelState
|
||||
from homeassistant.components.manual.alarm_control_panel import ( # pylint: disable=hass-component-root-import
|
||||
ManualAlarm,
|
||||
)
|
||||
from homeassistant.components.manual.alarm_control_panel import ManualAlarm
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ARMING_TIME, CONF_DELAY_TIME, CONF_TRIGGER_TIME
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
@@ -139,7 +139,6 @@ class DemoCover(CoverEntity):
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
self._is_opening = False
|
||||
self._is_closing = True
|
||||
self._listen_cover()
|
||||
self._requested_closing = True
|
||||
@@ -163,7 +162,6 @@ class DemoCover(CoverEntity):
|
||||
return
|
||||
|
||||
self._is_opening = True
|
||||
self._is_closing = False
|
||||
self._listen_cover()
|
||||
self._requested_closing = False
|
||||
self.async_write_ha_state()
|
||||
@@ -183,14 +181,10 @@ class DemoCover(CoverEntity):
|
||||
if self._position == position:
|
||||
return
|
||||
|
||||
self._is_closing = position < (self._position or 0)
|
||||
self._is_opening = not self._is_closing
|
||||
|
||||
self._listen_cover()
|
||||
self._requested_closing = (
|
||||
self._position is not None and position < self._position
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
|
||||
"""Move the cover til to a specific position."""
|
||||
|
@@ -3,14 +3,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.valve import ValveEntity, ValveEntityFeature, ValveState
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.event import async_track_utc_time_change
|
||||
|
||||
OPEN_CLOSE_DELAY = 2 # Used to give a realistic open/close experience in frontend
|
||||
|
||||
@@ -25,8 +23,6 @@ async def async_setup_entry(
|
||||
[
|
||||
DemoValve("Front Garden", ValveState.OPEN),
|
||||
DemoValve("Orchard", ValveState.CLOSED),
|
||||
DemoValve("Back Garden", ValveState.CLOSED, position=70),
|
||||
DemoValve("Trees", ValveState.CLOSED, position=30),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -41,7 +37,6 @@ class DemoValve(ValveEntity):
|
||||
name: str,
|
||||
state: str,
|
||||
moveable: bool = True,
|
||||
position: int | None = None,
|
||||
) -> None:
|
||||
"""Initialize the valve."""
|
||||
self._attr_name = name
|
||||
@@ -51,23 +46,11 @@ class DemoValve(ValveEntity):
|
||||
)
|
||||
self._state = state
|
||||
self._moveable = moveable
|
||||
self._attr_reports_position = False
|
||||
self._unsub_listener_valve: CALLBACK_TYPE | None = None
|
||||
self._set_position: int = 0
|
||||
self._position: int = 0
|
||||
if position is None:
|
||||
return
|
||||
|
||||
self._position = self._set_position = position
|
||||
self._attr_reports_position = True
|
||||
self._attr_supported_features |= (
|
||||
ValveEntityFeature.SET_POSITION | ValveEntityFeature.STOP
|
||||
)
|
||||
|
||||
@property
|
||||
def current_valve_position(self) -> int:
|
||||
"""Return current position of valve."""
|
||||
return self._position
|
||||
def is_open(self) -> bool:
|
||||
"""Return true if valve is open."""
|
||||
return self._state == ValveState.OPEN
|
||||
|
||||
@property
|
||||
def is_opening(self) -> bool:
|
||||
@@ -84,6 +67,11 @@ class DemoValve(ValveEntity):
|
||||
"""Return true if valve is closed."""
|
||||
return self._state == ValveState.CLOSED
|
||||
|
||||
@property
|
||||
def reports_position(self) -> bool:
|
||||
"""Return True if entity reports position, False otherwise."""
|
||||
return False
|
||||
|
||||
async def async_open_valve(self, **kwargs: Any) -> None:
|
||||
"""Open the valve."""
|
||||
self._state = ValveState.OPENING
|
||||
@@ -99,45 +87,3 @@ class DemoValve(ValveEntity):
|
||||
await asyncio.sleep(OPEN_CLOSE_DELAY)
|
||||
self._state = ValveState.CLOSED
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_stop_valve(self) -> None:
|
||||
"""Stop the valve."""
|
||||
self._state = ValveState.OPEN if self._position > 0 else ValveState.CLOSED
|
||||
if self._unsub_listener_valve is not None:
|
||||
self._unsub_listener_valve()
|
||||
self._unsub_listener_valve = None
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_set_valve_position(self, position: int) -> None:
|
||||
"""Move the valve to a specific position."""
|
||||
if position == self._position:
|
||||
return
|
||||
if position > self._position:
|
||||
self._state = ValveState.OPENING
|
||||
else:
|
||||
self._state = ValveState.CLOSING
|
||||
|
||||
self._set_position = round(position, -1)
|
||||
self._listen_valve()
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def _listen_valve(self) -> None:
|
||||
"""Listen for changes in valve."""
|
||||
if self._unsub_listener_valve is None:
|
||||
self._unsub_listener_valve = async_track_utc_time_change(
|
||||
self.hass, self._time_changed_valve
|
||||
)
|
||||
|
||||
async def _time_changed_valve(self, now: datetime) -> None:
|
||||
"""Track time changes."""
|
||||
if self._state == ValveState.OPENING:
|
||||
self._position += 10
|
||||
elif self._state == ValveState.CLOSING:
|
||||
self._position -= 10
|
||||
|
||||
if self._position in (100, 0, self._set_position):
|
||||
await self.async_stop_valve()
|
||||
return
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
@@ -196,7 +196,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
|
||||
self._attr_name = name if name is not None else f"{source_entity} derivative"
|
||||
self._attr_extra_state_attributes = {ATTR_SOURCE_ID: source_entity}
|
||||
self._unit_template: str | None = None
|
||||
|
||||
if unit_of_measurement is None:
|
||||
final_unit_prefix = "" if unit_prefix is None else unit_prefix
|
||||
self._unit_template = f"{final_unit_prefix}{{}}/{unit_time}"
|
||||
@@ -217,23 +217,6 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
lambda *args: None
|
||||
)
|
||||
|
||||
def _derive_and_set_attributes_from_state(self, source_state: State | None) -> None:
|
||||
if self._unit_template and source_state:
|
||||
original_unit = self._attr_native_unit_of_measurement
|
||||
source_unit = source_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||
self._attr_native_unit_of_measurement = self._unit_template.format(
|
||||
"" if source_unit is None else source_unit
|
||||
)
|
||||
if original_unit != self._attr_native_unit_of_measurement:
|
||||
_LOGGER.debug(
|
||||
"%s: Derivative sensor switched UoM from %s to %s, resetting state to 0",
|
||||
self.entity_id,
|
||||
original_unit,
|
||||
self._attr_native_unit_of_measurement,
|
||||
)
|
||||
self._state_list = []
|
||||
self._attr_native_value = round(Decimal(0), self._round_digits)
|
||||
|
||||
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)
|
||||
@@ -302,9 +285,6 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
except (InvalidOperation, TypeError):
|
||||
self._attr_native_value = None
|
||||
|
||||
source_state = self.hass.states.get(self._sensor_source_id)
|
||||
self._derive_and_set_attributes_from_state(source_state)
|
||||
|
||||
def schedule_max_sub_interval_exceeded(source_state: State | None) -> None:
|
||||
"""Schedule calculation using the source state and max_sub_interval.
|
||||
|
||||
@@ -378,18 +358,10 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
_LOGGER.debug("%s: New state changed event: %s", self.entity_id, event.data)
|
||||
self._cancel_max_sub_interval_exceeded_callback()
|
||||
new_state = event.data["new_state"]
|
||||
|
||||
if not self._handle_invalid_source_state(new_state):
|
||||
return
|
||||
|
||||
assert new_state
|
||||
|
||||
original_unit = self._attr_native_unit_of_measurement
|
||||
self._derive_and_set_attributes_from_state(new_state)
|
||||
if original_unit != self._attr_native_unit_of_measurement:
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
schedule_max_sub_interval_exceeded(new_state)
|
||||
old_state = event.data["old_state"]
|
||||
if old_state is not None:
|
||||
@@ -419,6 +391,12 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
if self.native_unit_of_measurement is None:
|
||||
unit = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||
self._attr_native_unit_of_measurement = self._unit_template.format(
|
||||
"" if unit is None else unit
|
||||
)
|
||||
|
||||
self._prune_state_list(new_timestamp)
|
||||
|
||||
try:
|
||||
|
@@ -2,12 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.const import ATTR_GPS_ACCURACY, STATE_HOME # noqa: F401
|
||||
from homeassistant.const import STATE_HOME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import bind_hass
|
||||
|
||||
from .config_entry import ( # noqa: F401
|
||||
from .config_entry import (
|
||||
ScannerEntity,
|
||||
ScannerEntityDescription,
|
||||
TrackerEntity,
|
||||
@@ -15,7 +15,7 @@ from .config_entry import ( # noqa: F401
|
||||
async_setup_entry,
|
||||
async_unload_entry,
|
||||
)
|
||||
from .const import ( # noqa: F401
|
||||
from .const import (
|
||||
ATTR_ATTRIBUTES,
|
||||
ATTR_BATTERY,
|
||||
ATTR_DEV_ID,
|
||||
@@ -37,7 +37,7 @@ from .const import ( # noqa: F401
|
||||
SCAN_INTERVAL,
|
||||
SourceType,
|
||||
)
|
||||
from .legacy import ( # noqa: F401
|
||||
from .legacy import (
|
||||
PLATFORM_SCHEMA,
|
||||
PLATFORM_SCHEMA_BASE,
|
||||
SERVICE_SEE,
|
||||
@@ -61,3 +61,44 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the device tracker."""
|
||||
async_setup_legacy_integration(hass, config)
|
||||
return True
|
||||
|
||||
|
||||
__all__ = (
|
||||
"ATTR_ATTRIBUTES",
|
||||
"ATTR_BATTERY",
|
||||
"ATTR_DEV_ID",
|
||||
"ATTR_GPS",
|
||||
"ATTR_HOST_NAME",
|
||||
"ATTR_IP",
|
||||
"ATTR_LOCATION_NAME",
|
||||
"ATTR_MAC",
|
||||
"ATTR_SOURCE_TYPE",
|
||||
"CONF_CONSIDER_HOME",
|
||||
"CONF_NEW_DEVICE_DEFAULTS",
|
||||
"CONF_SCAN_INTERVAL",
|
||||
"CONF_TRACK_NEW",
|
||||
"CONNECTED_DEVICE_REGISTERED",
|
||||
"DEFAULT_CONSIDER_HOME",
|
||||
"DEFAULT_TRACK_NEW",
|
||||
"DOMAIN",
|
||||
"ENTITY_ID_FORMAT",
|
||||
"PLATFORM_SCHEMA",
|
||||
"PLATFORM_SCHEMA_BASE",
|
||||
"SCAN_INTERVAL",
|
||||
"SERVICE_SEE",
|
||||
"SERVICE_SEE_PAYLOAD_SCHEMA",
|
||||
"SOURCE_TYPES",
|
||||
"AsyncSeeCallback",
|
||||
"DeviceScanner",
|
||||
"ScannerEntity",
|
||||
"ScannerEntityDescription",
|
||||
"SeeCallback",
|
||||
"SourceType",
|
||||
"TrackerEntity",
|
||||
"TrackerEntityDescription",
|
||||
"async_setup",
|
||||
"async_setup_entry",
|
||||
"async_unload_entry",
|
||||
"is_on",
|
||||
"see",
|
||||
)
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pydoods"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["pydoods==1.0.2", "Pillow==12.0.0"]
|
||||
"requirements": ["pydoods==1.0.2", "Pillow==11.3.0"]
|
||||
}
|
||||
|
@@ -4,7 +4,6 @@
|
||||
"codeowners": ["@mib1185", "@edenhaus", "@Augar"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==15.1.0"]
|
||||
|
@@ -8,11 +8,8 @@ from eheimdigital.classic_vario import EheimDigitalClassicVario
|
||||
from eheimdigital.device import EheimDigitalDevice
|
||||
from eheimdigital.types import FilterErrorCode
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
|
||||
from homeassistant.components.sensor.const import SensorDeviceClass
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
@@ -21,9 +21,6 @@ DEFAULT_STT_MODEL = "scribe_v1"
|
||||
DEFAULT_STYLE = 0
|
||||
DEFAULT_USE_SPEAKER_BOOST = True
|
||||
|
||||
MAX_REQUEST_IDS = 3
|
||||
MODELS_PREVIOUS_INFO_NOT_SUPPORTED = ("eleven_v3",)
|
||||
|
||||
STT_LANGUAGES = [
|
||||
"af-ZA", # Afrikaans
|
||||
"am-ET", # Amharic
|
||||
|
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["elevenlabs"],
|
||||
"requirements": ["elevenlabs==2.3.0", "sentence-stream==1.2.0"]
|
||||
"requirements": ["elevenlabs==2.3.0"]
|
||||
}
|
||||
|
@@ -85,4 +85,4 @@ rules:
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
strict-typing: done
|
||||
|
@@ -2,23 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import deque
|
||||
from collections.abc import AsyncGenerator, Mapping
|
||||
import contextlib
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from elevenlabs import AsyncElevenLabs
|
||||
from elevenlabs.core import ApiError
|
||||
from elevenlabs.types import Model, Voice as ElevenLabsVoice, VoiceSettings
|
||||
from sentence_stream import SentenceBoundaryDetector
|
||||
|
||||
from homeassistant.components.tts import (
|
||||
ATTR_VOICE,
|
||||
TextToSpeechEntity,
|
||||
TTSAudioRequest,
|
||||
TTSAudioResponse,
|
||||
TtsAudioType,
|
||||
Voice,
|
||||
)
|
||||
@@ -41,12 +35,10 @@ from .const import (
|
||||
DEFAULT_STYLE,
|
||||
DEFAULT_USE_SPEAKER_BOOST,
|
||||
DOMAIN,
|
||||
MAX_REQUEST_IDS,
|
||||
MODELS_PREVIOUS_INFO_NOT_SUPPORTED,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
PARALLEL_UPDATES = 6
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
def to_voice_settings(options: Mapping[str, Any]) -> VoiceSettings:
|
||||
@@ -130,12 +122,7 @@ class ElevenLabsTTSEntity(TextToSpeechEntity):
|
||||
self._attr_supported_languages = [
|
||||
lang.language_id for lang in self._model.languages or []
|
||||
]
|
||||
# Use the first supported language as the default if available
|
||||
self._attr_default_language = (
|
||||
self._attr_supported_languages[0]
|
||||
if self._attr_supported_languages
|
||||
else "en"
|
||||
)
|
||||
self._attr_default_language = self.supported_languages[0]
|
||||
|
||||
def async_get_supported_voices(self, language: str) -> list[Voice]:
|
||||
"""Return a list of supported voices for a language."""
|
||||
@@ -164,151 +151,3 @@ class ElevenLabsTTSEntity(TextToSpeechEntity):
|
||||
)
|
||||
raise HomeAssistantError(exc) from exc
|
||||
return "mp3", bytes_combined
|
||||
|
||||
async def async_stream_tts_audio(
|
||||
self, request: TTSAudioRequest
|
||||
) -> TTSAudioResponse:
|
||||
"""Generate speech from an incoming message."""
|
||||
_LOGGER.debug(
|
||||
"Getting TTS audio for language %s and options: %s",
|
||||
request.language,
|
||||
request.options,
|
||||
)
|
||||
return TTSAudioResponse("mp3", self._process_tts_stream(request))
|
||||
|
||||
async def _process_tts_stream(
|
||||
self, request: TTSAudioRequest
|
||||
) -> AsyncGenerator[bytes]:
|
||||
"""Generate speech from an incoming message."""
|
||||
text_stream = request.message_gen
|
||||
boundary_detector = SentenceBoundaryDetector()
|
||||
sentences: list[str] = []
|
||||
sentences_ready = asyncio.Event()
|
||||
sentences_complete = False
|
||||
|
||||
language_code: str | None = request.language
|
||||
voice_id = request.options.get(ATTR_VOICE, self._default_voice_id)
|
||||
model = request.options.get(ATTR_MODEL, self._model.model_id)
|
||||
|
||||
use_request_ids = model not in MODELS_PREVIOUS_INFO_NOT_SUPPORTED
|
||||
previous_request_ids: deque[str] = deque(maxlen=MAX_REQUEST_IDS)
|
||||
|
||||
base_stream_params = {
|
||||
"voice_id": voice_id,
|
||||
"model_id": model,
|
||||
"output_format": "mp3_44100_128",
|
||||
"voice_settings": self._voice_settings,
|
||||
}
|
||||
if language_code:
|
||||
base_stream_params["language_code"] = language_code
|
||||
|
||||
_LOGGER.debug("Starting TTS Stream with options: %s", base_stream_params)
|
||||
|
||||
async def _add_sentences() -> None:
|
||||
nonlocal sentences_complete
|
||||
|
||||
try:
|
||||
# Text chunks may not be on word or sentence boundaries
|
||||
async for text_chunk in text_stream:
|
||||
for sentence in boundary_detector.add_chunk(text_chunk):
|
||||
if not sentence.strip():
|
||||
continue
|
||||
|
||||
sentences.append(sentence)
|
||||
|
||||
if not sentences:
|
||||
continue
|
||||
|
||||
sentences_ready.set()
|
||||
|
||||
# Final sentence
|
||||
if text := boundary_detector.finish():
|
||||
sentences.append(text)
|
||||
finally:
|
||||
sentences_complete = True
|
||||
sentences_ready.set()
|
||||
|
||||
_add_sentences_task = self.hass.async_create_background_task(
|
||||
_add_sentences(), name="elevenlabs_tts_add_sentences"
|
||||
)
|
||||
|
||||
# Process new sentences as they're available, but synthesize the first
|
||||
# one immediately. While that's playing, synthesize (up to) the next 3
|
||||
# sentences. After that, synthesize all completed sentences as they're
|
||||
# available.
|
||||
sentence_schedule = [1, 3]
|
||||
while True:
|
||||
await sentences_ready.wait()
|
||||
|
||||
# Don't wait again if no more sentences are coming
|
||||
if not sentences_complete:
|
||||
sentences_ready.clear()
|
||||
|
||||
if not sentences:
|
||||
if sentences_complete:
|
||||
# Exit TTS loop
|
||||
_LOGGER.debug("No more sentences to process")
|
||||
break
|
||||
|
||||
# More sentences may be coming
|
||||
continue
|
||||
|
||||
new_sentences = sentences[:]
|
||||
sentences.clear()
|
||||
|
||||
while new_sentences:
|
||||
if sentence_schedule:
|
||||
max_sentences = sentence_schedule.pop(0)
|
||||
sentences_to_process = new_sentences[:max_sentences]
|
||||
new_sentences = new_sentences[len(sentences_to_process) :]
|
||||
else:
|
||||
# Process all available sentences together
|
||||
sentences_to_process = new_sentences[:]
|
||||
new_sentences.clear()
|
||||
|
||||
# Combine all new sentences completed to this point
|
||||
text = " ".join(sentences_to_process).strip()
|
||||
|
||||
if not text:
|
||||
continue
|
||||
|
||||
# Build kwargs common to both modes
|
||||
kwargs = base_stream_params | {
|
||||
"text": text,
|
||||
}
|
||||
|
||||
# Provide previous_request_ids if supported.
|
||||
if previous_request_ids:
|
||||
# Send previous request ids.
|
||||
kwargs["previous_request_ids"] = list(previous_request_ids)
|
||||
|
||||
# Synthesize audio while text chunks are still being accumulated
|
||||
_LOGGER.debug("Synthesizing TTS for text: %s", text)
|
||||
try:
|
||||
async with self._client.text_to_speech.with_raw_response.stream(
|
||||
**kwargs
|
||||
) as stream:
|
||||
async for chunk_bytes in stream.data:
|
||||
yield chunk_bytes
|
||||
|
||||
if use_request_ids:
|
||||
if (rid := stream.headers.get("request-id")) is not None:
|
||||
previous_request_ids.append(rid)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"No request-id returned from server; clearing previous requests"
|
||||
)
|
||||
previous_request_ids.clear()
|
||||
except ApiError as exc:
|
||||
_LOGGER.warning(
|
||||
"Error during processing of TTS request %s", exc, exc_info=True
|
||||
)
|
||||
_add_sentences_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await _add_sentences_task
|
||||
raise HomeAssistantError(exc) from exc
|
||||
|
||||
# Capture and store server request-id for next calls (only when supported)
|
||||
_LOGGER.debug("Completed TTS stream for text: %s", text)
|
||||
|
||||
_LOGGER.debug("Completed TTS stream")
|
||||
|
@@ -16,9 +16,7 @@ from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.components.sensor.recorder import ( # pylint: disable=hass-component-root-import
|
||||
reset_detected,
|
||||
)
|
||||
from homeassistant.components.sensor.recorder import reset_detected
|
||||
from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfEnergy, UnitOfVolume
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/epson",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["epson_projector"],
|
||||
"requirements": ["epson-projector==0.6.0"]
|
||||
"requirements": ["epson-projector==0.5.1"]
|
||||
}
|
||||
|
@@ -10,8 +10,8 @@ from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.components.sensor.const import SensorStateClass
|
||||
from homeassistant.const import PERCENTAGE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
@@ -9,7 +9,6 @@ from typing import Any, cast
|
||||
from aioesphomeapi import (
|
||||
ClimateAction,
|
||||
ClimateFanMode,
|
||||
ClimateFeature,
|
||||
ClimateInfo,
|
||||
ClimateMode,
|
||||
ClimatePreset,
|
||||
@@ -135,16 +134,12 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
|
||||
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_translation_key = "climate"
|
||||
_feature_flags = ClimateFeature(0)
|
||||
|
||||
@callback
|
||||
def _on_static_info_update(self, static_info: EntityInfo) -> None:
|
||||
"""Set attrs from static info."""
|
||||
super()._on_static_info_update(static_info)
|
||||
static_info = self._static_info
|
||||
self._feature_flags = ClimateFeature(
|
||||
static_info.supported_feature_flags_compat(self._api_version)
|
||||
)
|
||||
self._attr_precision = self._get_precision()
|
||||
self._attr_hvac_modes = [
|
||||
_CLIMATE_MODES.from_esphome(mode) for mode in static_info.supported_modes
|
||||
@@ -168,18 +163,11 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
|
||||
self._attr_max_temp = static_info.visual_max_temperature
|
||||
self._attr_min_humidity = round(static_info.visual_min_humidity)
|
||||
self._attr_max_humidity = round(static_info.visual_max_humidity)
|
||||
features = ClimateEntityFeature(0)
|
||||
if self._feature_flags & ClimateFeature.SUPPORTS_TARGET_HUMIDITY:
|
||||
features |= ClimateEntityFeature.TARGET_HUMIDITY
|
||||
if self._feature_flags & ClimateFeature.REQUIRES_TWO_POINT_TARGET_TEMPERATURE:
|
||||
features = ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
if static_info.supports_two_point_target_temperature:
|
||||
features |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
|
||||
else:
|
||||
features |= ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
if (
|
||||
self._feature_flags
|
||||
& ClimateFeature.SUPPORTS_TWO_POINT_TARGET_TEMPERATURE
|
||||
):
|
||||
features |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
|
||||
if static_info.supports_target_humidity:
|
||||
features |= ClimateEntityFeature.TARGET_HUMIDITY
|
||||
if self.preset_modes:
|
||||
features |= ClimateEntityFeature.PRESET_MODE
|
||||
if self.fan_modes:
|
||||
@@ -215,7 +203,7 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
|
||||
def hvac_action(self) -> HVACAction | None:
|
||||
"""Return current action."""
|
||||
# HA has no support feature field for hvac_action
|
||||
if not self._feature_flags & ClimateFeature.SUPPORTS_ACTION:
|
||||
if not self._static_info.supports_action:
|
||||
return None
|
||||
return _CLIMATE_ACTIONS.from_esphome(self._state.action)
|
||||
|
||||
@@ -245,7 +233,7 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
|
||||
@esphome_float_state_property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
if not self._feature_flags & ClimateFeature.SUPPORTS_CURRENT_TEMPERATURE:
|
||||
if not self._static_info.supports_current_temperature:
|
||||
return None
|
||||
return self._state.current_temperature
|
||||
|
||||
@@ -254,7 +242,7 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
|
||||
def current_humidity(self) -> int | None:
|
||||
"""Return the current humidity."""
|
||||
if (
|
||||
(not self._feature_flags & ClimateFeature.SUPPORTS_CURRENT_HUMIDITY)
|
||||
not self._static_info.supports_current_humidity
|
||||
or (val := self._state.current_humidity) is None
|
||||
or not isfinite(val)
|
||||
):
|
||||
@@ -266,11 +254,7 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the temperature we try to reach."""
|
||||
if (
|
||||
not self._feature_flags
|
||||
& (
|
||||
ClimateFeature.REQUIRES_TWO_POINT_TARGET_TEMPERATURE
|
||||
| ClimateFeature.SUPPORTS_TWO_POINT_TARGET_TEMPERATURE
|
||||
)
|
||||
not self._static_info.supports_two_point_target_temperature
|
||||
and self.hvac_mode != HVACMode.AUTO
|
||||
):
|
||||
return self._state.target_temperature
|
||||
@@ -311,10 +295,7 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
|
||||
cast(HVACMode, kwargs[ATTR_HVAC_MODE])
|
||||
)
|
||||
if ATTR_TEMPERATURE in kwargs:
|
||||
if not self._feature_flags & (
|
||||
ClimateFeature.REQUIRES_TWO_POINT_TARGET_TEMPERATURE
|
||||
| ClimateFeature.SUPPORTS_TWO_POINT_TARGET_TEMPERATURE
|
||||
):
|
||||
if not self._static_info.supports_two_point_target_temperature:
|
||||
data["target_temperature"] = kwargs[ATTR_TEMPERATURE]
|
||||
else:
|
||||
hvac_mode = kwargs.get(ATTR_HVAC_MODE) or self.hvac_mode
|
||||
|
@@ -542,16 +542,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
# Check if Z-Wave capabilities are present and start discovery flow
|
||||
next_flow_id: str | None = None
|
||||
# If the zwave_home_id is not set, we don't know if it's a fresh
|
||||
# adapter, or the cable is just unplugged. So only start
|
||||
# the zwave_js config flow automatically if there is a
|
||||
# zwave_home_id present. If it's a fresh adapter, the manager
|
||||
# will handle starting the flow once it gets the home id changed
|
||||
# request from the ESPHome device.
|
||||
if (
|
||||
self._device_info.zwave_proxy_feature_flags
|
||||
and self._device_info.zwave_home_id
|
||||
):
|
||||
if self._device_info.zwave_proxy_feature_flags:
|
||||
assert self._connected_address is not None
|
||||
assert self._port is not None
|
||||
|
||||
@@ -568,7 +559,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
},
|
||||
data=ESPHomeServiceInfo(
|
||||
name=self._device_info.name,
|
||||
zwave_home_id=self._device_info.zwave_home_id,
|
||||
zwave_home_id=self._device_info.zwave_home_id or None,
|
||||
ip_address=self._connected_address,
|
||||
port=self._port,
|
||||
noise_psk=self._noise_psk,
|
||||
|
@@ -491,30 +491,13 @@ class RuntimeEntryData:
|
||||
|
||||
assert self.client.connected_address
|
||||
|
||||
# If the device does not have a zwave_home_id, it means
|
||||
# either the Z-Wave controller has never been connected
|
||||
# to the ESPHome device, or the Z-Wave controller has
|
||||
# never been provisioned with a home ID (brand new).
|
||||
# Since we cannot tell the difference, and it could
|
||||
# just be the cable is unplugged we only
|
||||
# automatically start the flow if we have a home ID.
|
||||
if not device_info.zwave_home_id:
|
||||
return
|
||||
|
||||
self.async_create_zwave_js_flow(hass, device_info, device_info.zwave_home_id)
|
||||
|
||||
def async_create_zwave_js_flow(
|
||||
self, hass: HomeAssistant, device_info: DeviceInfo, zwave_home_id: int
|
||||
) -> None:
|
||||
"""Create a zwave_js config flow for a Z-Wave JS Proxy device."""
|
||||
assert self.client.connected_address is not None
|
||||
discovery_flow.async_create_flow(
|
||||
hass,
|
||||
"zwave_js",
|
||||
{"source": config_entries.SOURCE_ESPHOME},
|
||||
ESPHomeServiceInfo(
|
||||
name=device_info.name,
|
||||
zwave_home_id=zwave_home_id,
|
||||
zwave_home_id=device_info.zwave_home_id or None,
|
||||
ip_address=self.client.connected_address,
|
||||
port=self.client.port,
|
||||
noise_psk=self.client.noise_psk,
|
||||
|
@@ -6,7 +6,6 @@ import base64
|
||||
from functools import partial
|
||||
import logging
|
||||
import secrets
|
||||
import struct
|
||||
from typing import TYPE_CHECKING, Any, NamedTuple
|
||||
|
||||
from aioesphomeapi import (
|
||||
@@ -23,8 +22,6 @@ from aioesphomeapi import (
|
||||
RequiresEncryptionAPIError,
|
||||
UserService,
|
||||
UserServiceArgType,
|
||||
ZWaveProxyRequest,
|
||||
ZWaveProxyRequestType,
|
||||
parse_log_message,
|
||||
)
|
||||
from awesomeversion import AwesomeVersion
|
||||
@@ -47,18 +44,12 @@ from homeassistant.core import (
|
||||
State,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import (
|
||||
HomeAssistantError,
|
||||
ServiceNotFound,
|
||||
ServiceValidationError,
|
||||
TemplateError,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError, TemplateError
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
issue_registry as ir,
|
||||
json,
|
||||
template,
|
||||
)
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
@@ -93,8 +84,6 @@ from .encryption_key_storage import async_get_encryption_key_storage
|
||||
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
|
||||
|
||||
DEVICE_CONFLICT_ISSUE_FORMAT = "device_conflict-{}"
|
||||
UNPACK_UINT32_BE = struct.Struct(">I").unpack_from
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from aioesphomeapi.api_pb2 import SubscribeLogsResponse # type: ignore[attr-defined] # noqa: I001
|
||||
@@ -279,32 +268,11 @@ class ESPHomeManager:
|
||||
elif self.entry.options.get(
|
||||
CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS
|
||||
):
|
||||
call_id = service.call_id
|
||||
if call_id and service.wants_response:
|
||||
# Service call with response expected
|
||||
self.entry.async_create_task(
|
||||
hass,
|
||||
self._handle_service_call_with_response(
|
||||
domain,
|
||||
service_name,
|
||||
service_data,
|
||||
call_id,
|
||||
service.response_template,
|
||||
),
|
||||
)
|
||||
elif call_id:
|
||||
# Service call without response but needs success/failure notification
|
||||
self.entry.async_create_task(
|
||||
hass,
|
||||
self._handle_service_call_with_notification(
|
||||
domain, service_name, service_data, call_id
|
||||
),
|
||||
)
|
||||
else:
|
||||
# Fire and forget service call
|
||||
self.entry.async_create_task(
|
||||
hass, hass.services.async_call(domain, service_name, service_data)
|
||||
hass.async_create_task(
|
||||
hass.services.async_call(
|
||||
domain, service_name, service_data, blocking=True
|
||||
)
|
||||
)
|
||||
else:
|
||||
device_info = self.entry_data.device_info
|
||||
assert device_info is not None
|
||||
@@ -330,98 +298,6 @@ class ESPHomeManager:
|
||||
service_data,
|
||||
)
|
||||
|
||||
async def _handle_service_call_with_response(
|
||||
self,
|
||||
domain: str,
|
||||
service_name: str,
|
||||
service_data: dict,
|
||||
call_id: int,
|
||||
response_template: str | None = None,
|
||||
) -> None:
|
||||
"""Handle service call that expects a response and send response back to ESPHome."""
|
||||
try:
|
||||
# Call the service with response capture enabled
|
||||
action_response = await self.hass.services.async_call(
|
||||
domain=domain,
|
||||
service=service_name,
|
||||
service_data=service_data,
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
if response_template:
|
||||
try:
|
||||
# Render response template
|
||||
tmpl = Template(response_template, self.hass)
|
||||
response = tmpl.async_render(
|
||||
variables={"response": action_response},
|
||||
strict=True,
|
||||
)
|
||||
response_dict = {"response": response}
|
||||
|
||||
except TemplateError as ex:
|
||||
raise HomeAssistantError(
|
||||
f"Error rendering response template: {ex}"
|
||||
) from ex
|
||||
else:
|
||||
response_dict = {"response": action_response}
|
||||
|
||||
# JSON encode response data for ESPHome
|
||||
response_data = json.json_bytes(response_dict)
|
||||
|
||||
except (
|
||||
ServiceNotFound,
|
||||
ServiceValidationError,
|
||||
vol.Invalid,
|
||||
HomeAssistantError,
|
||||
) as ex:
|
||||
self._send_service_call_response(
|
||||
call_id, success=False, error_message=str(ex), response_data=b""
|
||||
)
|
||||
|
||||
else:
|
||||
# Send success response back to ESPHome
|
||||
self._send_service_call_response(
|
||||
call_id=call_id,
|
||||
success=True,
|
||||
error_message="",
|
||||
response_data=response_data,
|
||||
)
|
||||
|
||||
async def _handle_service_call_with_notification(
|
||||
self, domain: str, service_name: str, service_data: dict, call_id: int
|
||||
) -> None:
|
||||
"""Handle service call that needs success/failure notification."""
|
||||
try:
|
||||
await self.hass.services.async_call(
|
||||
domain, service_name, service_data, blocking=True
|
||||
)
|
||||
except (ServiceNotFound, ServiceValidationError, vol.Invalid) as ex:
|
||||
self._send_service_call_response(call_id, False, str(ex), b"")
|
||||
else:
|
||||
self._send_service_call_response(call_id, True, "", b"")
|
||||
|
||||
def _send_service_call_response(
|
||||
self,
|
||||
call_id: int,
|
||||
success: bool,
|
||||
error_message: str,
|
||||
response_data: bytes,
|
||||
) -> None:
|
||||
"""Send service call response back to ESPHome device."""
|
||||
_LOGGER.debug(
|
||||
"Service call response for call_id %s: success=%s, error=%s",
|
||||
call_id,
|
||||
success,
|
||||
error_message,
|
||||
)
|
||||
self.cli.send_homeassistant_action_response(
|
||||
call_id,
|
||||
success,
|
||||
error_message,
|
||||
response_data,
|
||||
)
|
||||
|
||||
@callback
|
||||
def _send_home_assistant_state(
|
||||
self, entity_id: str, attribute: str | None, state: State | None
|
||||
@@ -681,11 +557,6 @@ class ESPHomeManager:
|
||||
)
|
||||
entry_data.loaded_platforms.add(Platform.ASSIST_SATELLITE)
|
||||
|
||||
if device_info.zwave_proxy_feature_flags:
|
||||
entry_data.disconnect_callbacks.add(
|
||||
cli.subscribe_zwave_proxy_request(self._async_zwave_proxy_request)
|
||||
)
|
||||
|
||||
cli.subscribe_home_assistant_states_and_services(
|
||||
on_state=entry_data.async_update_state,
|
||||
on_service_call=self.async_on_service_call,
|
||||
@@ -697,25 +568,6 @@ class ESPHomeManager:
|
||||
_async_check_firmware_version(hass, device_info, api_version)
|
||||
_async_check_using_api_password(hass, device_info, bool(self.password))
|
||||
|
||||
def _async_zwave_proxy_request(self, request: ZWaveProxyRequest) -> None:
|
||||
"""Handle a request to create a zwave_js config flow."""
|
||||
if request.type != ZWaveProxyRequestType.HOME_ID_CHANGE:
|
||||
return
|
||||
# ESPHome will send a home id change on every connection
|
||||
# if the Z-Wave controller is connected to the ESPHome device
|
||||
# so we know for sure that the Z-Wave controller is connected
|
||||
# when we get the message. This makes it safe to start
|
||||
# the zwave_js config flow automatically even if the zwave_home_id
|
||||
# is 0 (not yet provisioned) as we know for sure the controller
|
||||
# is connected to the ESPHome device and do not have to guess
|
||||
# if it's a broken connection or Z-Wave controller or a not
|
||||
# yet provisioned controller.
|
||||
zwave_home_id: int = UNPACK_UINT32_BE(request.data[0:4])[0]
|
||||
assert self.entry_data.device_info is not None
|
||||
self.entry_data.async_create_zwave_js_flow(
|
||||
self.hass, self.entry_data.device_info, zwave_home_id
|
||||
)
|
||||
|
||||
async def on_disconnect(self, expected_disconnect: bool) -> None:
|
||||
"""Run disconnect callbacks on API disconnect."""
|
||||
entry_data = self.entry_data
|
||||
|
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==42.2.0",
|
||||
"aioesphomeapi==41.16.0",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.4.0"
|
||||
],
|
||||
|
@@ -6,7 +6,7 @@ from dataclasses import replace
|
||||
|
||||
from aioesphomeapi import EntityInfo, SelectInfo, SelectState
|
||||
|
||||
from homeassistant.components.assist_pipeline import (
|
||||
from homeassistant.components.assist_pipeline.select import (
|
||||
AssistPipelineSelect,
|
||||
VadSensitivitySelect,
|
||||
)
|
||||
|
@@ -4,7 +4,6 @@
|
||||
"codeowners": ["@mib1185"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/feedreader",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["feedparser", "sgmllib3k"],
|
||||
"requirements": ["feedparser==6.0.12"]
|
||||
|
@@ -19,9 +19,7 @@ from homeassistant.components.ffmpeg import (
|
||||
FFmpegManager,
|
||||
get_ffmpeg_manager,
|
||||
)
|
||||
from homeassistant.components.ffmpeg_motion.binary_sensor import ( # pylint: disable=hass-component-root-import
|
||||
FFmpegBinarySensor,
|
||||
)
|
||||
from homeassistant.components.ffmpeg_motion.binary_sensor import FFmpegBinarySensor
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
@@ -1,26 +0,0 @@
|
||||
"""Diagnostics for the Firefly III integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_API_KEY, CONF_URL
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import FireflyConfigEntry
|
||||
from .coordinator import FireflyDataUpdateCoordinator
|
||||
|
||||
TO_REDACT = [CONF_API_KEY, CONF_URL]
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: FireflyConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator: FireflyDataUpdateCoordinator = entry.runtime_data
|
||||
|
||||
return {
|
||||
"config_entry": async_redact_data(entry.as_dict(), TO_REDACT),
|
||||
"data": {"primary_currency": coordinator.data.primary_currency.to_dict()},
|
||||
}
|
@@ -2,14 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pyfirefly.models import Account, Category
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.const import CONF_URL
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER, NAME
|
||||
from .const import DOMAIN, MANUFACTURER
|
||||
from .coordinator import FireflyDataUpdateCoordinator
|
||||
|
||||
|
||||
@@ -21,65 +21,20 @@ class FireflyBaseEntity(CoordinatorEntity[FireflyDataUpdateCoordinator]):
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: FireflyDataUpdateCoordinator,
|
||||
entity_description: EntityDescription,
|
||||
) -> None:
|
||||
"""Initialize a Firefly entity."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self.entity_description = entity_description
|
||||
self._attr_device_info = DeviceInfo(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
manufacturer=MANUFACTURER,
|
||||
name=NAME,
|
||||
configuration_url=URL(coordinator.config_entry.data[CONF_URL]),
|
||||
identifiers={(DOMAIN, f"{coordinator.config_entry.entry_id}_service")},
|
||||
)
|
||||
|
||||
|
||||
class FireflyAccountBaseEntity(FireflyBaseEntity):
|
||||
"""Base class for Firefly III account entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: FireflyDataUpdateCoordinator,
|
||||
account: Account,
|
||||
key: str,
|
||||
) -> None:
|
||||
"""Initialize a Firefly account entity."""
|
||||
super().__init__(coordinator)
|
||||
self._account = account
|
||||
self._attr_device_info = DeviceInfo(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
manufacturer=MANUFACTURER,
|
||||
name=account.attributes.name,
|
||||
configuration_url=f"{URL(coordinator.config_entry.data[CONF_URL])}/accounts/show/{account.id}",
|
||||
identifiers={
|
||||
(DOMAIN, f"{coordinator.config_entry.entry_id}_account_{account.id}")
|
||||
(
|
||||
DOMAIN,
|
||||
f"{coordinator.config_entry.entry_id}_{self.entity_description.key}",
|
||||
)
|
||||
},
|
||||
)
|
||||
self._attr_unique_id = (
|
||||
f"{coordinator.config_entry.unique_id}_account_{account.id}_{key}"
|
||||
)
|
||||
|
||||
|
||||
class FireflyCategoryBaseEntity(FireflyBaseEntity):
|
||||
"""Base class for Firefly III category entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: FireflyDataUpdateCoordinator,
|
||||
category: Category,
|
||||
key: str,
|
||||
) -> None:
|
||||
"""Initialize a Firefly category entity."""
|
||||
super().__init__(coordinator)
|
||||
self._category = category
|
||||
self._attr_device_info = DeviceInfo(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
manufacturer=MANUFACTURER,
|
||||
name=category.attributes.name,
|
||||
configuration_url=f"{URL(coordinator.config_entry.data[CONF_URL])}/categories/show/{category.id}",
|
||||
identifiers={
|
||||
(DOMAIN, f"{coordinator.config_entry.entry_id}_category_{category.id}")
|
||||
},
|
||||
)
|
||||
self._attr_unique_id = (
|
||||
f"{coordinator.config_entry.unique_id}_category_{category.id}_{key}"
|
||||
)
|
||||
|
@@ -2,13 +2,13 @@
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"account_type": {
|
||||
"default": "mdi:bank"
|
||||
},
|
||||
"account_balance": {
|
||||
"default": "mdi:currency-usd"
|
||||
},
|
||||
"account_role": {
|
||||
"default": "mdi:account-circle"
|
||||
"default": "mdi:bank",
|
||||
"state": {
|
||||
"expense": "mdi:cash-minus",
|
||||
"revenue": "mdi:cash-plus",
|
||||
"asset": "mdi:account-cash",
|
||||
"liability": "mdi:hand-coin"
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"default": "mdi:label"
|
||||
|
@@ -5,36 +5,34 @@ from __future__ import annotations
|
||||
from pyfirefly.models import Account, Category
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
StateType,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.components.sensor.const import SensorDeviceClass
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import FireflyConfigEntry, FireflyDataUpdateCoordinator
|
||||
from .entity import FireflyAccountBaseEntity, FireflyCategoryBaseEntity
|
||||
from .entity import FireflyBaseEntity
|
||||
|
||||
ACCOUNT_ROLE_MAPPING = {
|
||||
"defaultAsset": "default_asset",
|
||||
"sharedAsset": "shared_asset",
|
||||
"savingAsset": "saving_asset",
|
||||
"ccAsset": "cc_asset",
|
||||
"cashWalletAsset": "cash_wallet_asset",
|
||||
}
|
||||
ACCOUNT_TYPE_ICONS = {
|
||||
"expense": "mdi:cash-minus",
|
||||
"asset": "mdi:account-cash",
|
||||
"revenue": "mdi:cash-plus",
|
||||
"liability": "mdi:hand-coin",
|
||||
}
|
||||
ACCOUNT_SENSORS: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
key="account_type",
|
||||
translation_key="account",
|
||||
device_class=SensorDeviceClass.MONETARY,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
),
|
||||
)
|
||||
|
||||
ACCOUNT_BALANCE = "account_balance"
|
||||
ACCOUNT_ROLE = "account_role"
|
||||
ACCOUNT_TYPE = "account_type"
|
||||
CATEGORY = "category"
|
||||
CATEGORY_SENSORS: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
key="category",
|
||||
translation_key="category",
|
||||
device_class=SensorDeviceClass.MONETARY,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -42,137 +40,94 @@ async def async_setup_entry(
|
||||
entry: FireflyConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Firefly III sensors."""
|
||||
"""Set up the Firefly III sensor platform."""
|
||||
coordinator = entry.runtime_data
|
||||
entities: list[SensorEntity] = []
|
||||
|
||||
for account in coordinator.data.accounts:
|
||||
entities.append(
|
||||
FireflyAccountBalanceSensor(coordinator, account, ACCOUNT_BALANCE)
|
||||
entities: list[SensorEntity] = [
|
||||
FireflyAccountEntity(
|
||||
coordinator=coordinator,
|
||||
entity_description=description,
|
||||
account=account,
|
||||
)
|
||||
entities.append(FireflyAccountRoleSensor(coordinator, account, ACCOUNT_ROLE))
|
||||
entities.append(FireflyAccountTypeSensor(coordinator, account, ACCOUNT_TYPE))
|
||||
for account in coordinator.data.accounts
|
||||
for description in ACCOUNT_SENSORS
|
||||
]
|
||||
|
||||
entities.extend(
|
||||
[
|
||||
FireflyCategorySensor(coordinator, category, CATEGORY)
|
||||
for category in coordinator.data.category_details
|
||||
]
|
||||
FireflyCategoryEntity(
|
||||
coordinator=coordinator,
|
||||
entity_description=description,
|
||||
category=category,
|
||||
)
|
||||
for category in coordinator.data.category_details
|
||||
for description in CATEGORY_SENSORS
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class FireflyAccountBalanceSensor(FireflyAccountBaseEntity, SensorEntity):
|
||||
"""Account balance sensor."""
|
||||
|
||||
_attr_translation_key = "account_balance"
|
||||
_attr_device_class = SensorDeviceClass.MONETARY
|
||||
_attr_state_class = SensorStateClass.TOTAL
|
||||
class FireflyAccountEntity(FireflyBaseEntity, SensorEntity):
|
||||
"""Entity for Firefly III account."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: FireflyDataUpdateCoordinator,
|
||||
entity_description: SensorEntityDescription,
|
||||
account: Account,
|
||||
key: str,
|
||||
) -> None:
|
||||
"""Initialize the account balance sensor."""
|
||||
super().__init__(coordinator, account, key)
|
||||
"""Initialize Firefly account entity."""
|
||||
super().__init__(coordinator, entity_description)
|
||||
self._account = account
|
||||
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{entity_description.key}_{account.id}"
|
||||
self._attr_name = account.attributes.name
|
||||
self._attr_native_unit_of_measurement = (
|
||||
coordinator.data.primary_currency.attributes.code
|
||||
)
|
||||
|
||||
# Account type state doesn't go well with the icons.json. Need to fix it.
|
||||
if account.attributes.type == "expense":
|
||||
self._attr_icon = "mdi:cash-minus"
|
||||
elif account.attributes.type == "asset":
|
||||
self._attr_icon = "mdi:account-cash"
|
||||
elif account.attributes.type == "revenue":
|
||||
self._attr_icon = "mdi:cash-plus"
|
||||
elif account.attributes.type == "liability":
|
||||
self._attr_icon = "mdi:hand-coin"
|
||||
else:
|
||||
self._attr_icon = "mdi:bank"
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return current account balance."""
|
||||
def native_value(self) -> str | None:
|
||||
"""Return the state of the sensor."""
|
||||
return self._account.attributes.current_balance
|
||||
|
||||
|
||||
class FireflyAccountRoleSensor(FireflyAccountBaseEntity, SensorEntity):
|
||||
"""Account role diagnostic sensor."""
|
||||
|
||||
_attr_translation_key = "account_role"
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_entity_registry_enabled_default = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: FireflyDataUpdateCoordinator,
|
||||
account: Account,
|
||||
key: str,
|
||||
) -> None:
|
||||
"""Initialize the account role sensor."""
|
||||
super().__init__(coordinator, account, key)
|
||||
self._account = account
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return account role."""
|
||||
|
||||
# An account can be empty and then should resort to Unknown
|
||||
account_role: str | None = self._account.attributes.account_role
|
||||
if account_role is None:
|
||||
return None
|
||||
|
||||
return ACCOUNT_ROLE_MAPPING.get(account_role, account_role)
|
||||
|
||||
|
||||
class FireflyAccountTypeSensor(FireflyAccountBaseEntity, SensorEntity):
|
||||
"""Account type diagnostic sensor."""
|
||||
|
||||
_attr_translation_key = "account_type"
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_entity_registry_enabled_default = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: FireflyDataUpdateCoordinator,
|
||||
account: Account,
|
||||
key: str,
|
||||
) -> None:
|
||||
"""Initialize the account type sensor."""
|
||||
super().__init__(coordinator, account, key)
|
||||
acc_type = account.attributes.type
|
||||
self._attr_icon = (
|
||||
ACCOUNT_TYPE_ICONS.get(acc_type, "mdi:bank")
|
||||
if acc_type is not None
|
||||
else "mdi:bank"
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return account type."""
|
||||
return self._account.attributes.type
|
||||
|
||||
|
||||
class FireflyCategorySensor(FireflyCategoryBaseEntity, SensorEntity):
|
||||
"""Category sensor."""
|
||||
|
||||
_attr_translation_key = "category"
|
||||
_attr_device_class = SensorDeviceClass.MONETARY
|
||||
_attr_state_class = SensorStateClass.TOTAL
|
||||
class FireflyCategoryEntity(FireflyBaseEntity, SensorEntity):
|
||||
"""Entity for Firefly III category."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: FireflyDataUpdateCoordinator,
|
||||
entity_description: SensorEntityDescription,
|
||||
category: Category,
|
||||
key: str,
|
||||
) -> None:
|
||||
"""Initialize the category sensor."""
|
||||
super().__init__(coordinator, category, key)
|
||||
"""Initialize Firefly category entity."""
|
||||
super().__init__(coordinator, entity_description)
|
||||
self._category = category
|
||||
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{entity_description.key}_{category.id}"
|
||||
self._attr_name = category.attributes.name
|
||||
self._attr_native_unit_of_measurement = (
|
||||
coordinator.data.primary_currency.attributes.code
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return net spent+earned value for this category in the period."""
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the state of the sensor."""
|
||||
spent_items = self._category.attributes.spent or []
|
||||
earned_items = self._category.attributes.earned or []
|
||||
|
||||
spent = sum(float(item.sum) for item in spent_items if item.sum is not None)
|
||||
earned = sum(float(item.sum) for item in earned_items if item.sum is not None)
|
||||
|
||||
if spent == 0 and earned == 0:
|
||||
return None
|
||||
return spent + earned
|
||||
|
@@ -45,34 +45,5 @@
|
||||
"timeout_connect": {
|
||||
"message": "A timeout occurred while trying to connect to the Firefly instance: {error}"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"account_balance": {
|
||||
"name": "Account Balance"
|
||||
},
|
||||
"account_role": {
|
||||
"name": "Account Role",
|
||||
"state": {
|
||||
"default_asset": "Default asset",
|
||||
"shared_asset": "Shared asset",
|
||||
"saving_asset": "Saving asset",
|
||||
"cc_asset": "Credit card asset",
|
||||
"cash_wallet_asset": "Cash wallet asset"
|
||||
}
|
||||
},
|
||||
"account_type": {
|
||||
"name": "Account Type",
|
||||
"state": {
|
||||
"asset": "Asset",
|
||||
"expense": "Expense",
|
||||
"revenue": "Revenue",
|
||||
"liability": "Liability"
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"name": "Earned/Spent"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -111,12 +111,7 @@ class FlumeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors[CONF_PASSWORD] = "invalid_auth"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=DATA_SCHEMA,
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"api_url": "https://portal.flumetech.com/settings#token"
|
||||
},
|
||||
step_id="user", data_schema=DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
|
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "In order to access the Flume Personal API, you will need to request a 'Client ID' and 'Client Secret' at {api_url}",
|
||||
"description": "In order to access the Flume Personal API, you will need to request a 'Client ID' and 'Client Secret' at https://portal.flumetech.com/settings#token",
|
||||
"title": "Connect to your Flume account",
|
||||
"data": {
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
|
@@ -6,8 +6,9 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.camera import CameraEntityFeature
|
||||
from homeassistant.components.ffmpeg import CONF_EXTRA_ARGUMENTS, CONF_INPUT
|
||||
from homeassistant.components.ffmpeg.camera import ( # pylint: disable=hass-component-root-import
|
||||
from homeassistant.components.ffmpeg.camera import (
|
||||
CONF_EXTRA_ARGUMENTS,
|
||||
CONF_INPUT,
|
||||
DEFAULT_ARGUMENTS,
|
||||
FFmpegCamera,
|
||||
)
|
||||
|
@@ -14,7 +14,6 @@ from homeassistant.helpers import aiohttp_client
|
||||
from .const import DOMAIN
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str})
|
||||
API_KEY_URL = "https://freedompro.eu/"
|
||||
|
||||
|
||||
class Hub:
|
||||
@@ -54,11 +53,7 @@ class FreedomProConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Show the setup form to the user."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
description_placeholders={
|
||||
"api_key_url": API_KEY_URL,
|
||||
},
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA
|
||||
)
|
||||
|
||||
errors = {}
|
||||
@@ -73,12 +68,7 @@ class FreedomProConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_create_entry(title="Freedompro", data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"api_key_url": API_KEY_URL,
|
||||
},
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
|
||||
|
@@ -5,7 +5,7 @@
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
},
|
||||
"description": "Please enter the API key obtained from {api_key_url}",
|
||||
"description": "Please enter the API key obtained from https://home.freedompro.eu",
|
||||
"title": "Freedompro API key"
|
||||
}
|
||||
},
|
||||
|
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
@@ -17,7 +16,6 @@ from fritzconnection.core.exceptions import (
|
||||
FritzConnectionException,
|
||||
FritzSecurityError,
|
||||
)
|
||||
from fritzconnection.lib.fritzcall import FritzCall
|
||||
from fritzconnection.lib.fritzhosts import FritzHosts
|
||||
from fritzconnection.lib.fritzstatus import FritzStatus
|
||||
from fritzconnection.lib.fritzwlan import FritzGuestWLAN
|
||||
@@ -122,7 +120,6 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
self.fritz_guest_wifi: FritzGuestWLAN = None
|
||||
self.fritz_hosts: FritzHosts = None
|
||||
self.fritz_status: FritzStatus = None
|
||||
self.fritz_call: FritzCall = None
|
||||
self.host = host
|
||||
self.mesh_role = MeshRoles.NONE
|
||||
self.mesh_wifi_uplink = False
|
||||
@@ -186,7 +183,6 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
self.fritz_hosts = FritzHosts(fc=self.connection)
|
||||
self.fritz_guest_wifi = FritzGuestWLAN(fc=self.connection)
|
||||
self.fritz_status = FritzStatus(fc=self.connection)
|
||||
self.fritz_call = FritzCall(fc=self.connection)
|
||||
info = self.fritz_status.get_device_info()
|
||||
|
||||
_LOGGER.debug(
|
||||
@@ -621,14 +617,6 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
self.fritz_guest_wifi.set_password, password, length
|
||||
)
|
||||
|
||||
async def async_trigger_dial(self, number: str, max_ring_seconds: int) -> None:
|
||||
"""Trigger service to dial a number."""
|
||||
try:
|
||||
await self.hass.async_add_executor_job(self.fritz_call.dial, number)
|
||||
await asyncio.sleep(max_ring_seconds)
|
||||
finally:
|
||||
await self.hass.async_add_executor_job(self.fritz_call.hangup)
|
||||
|
||||
async def async_trigger_cleanup(self) -> None:
|
||||
"""Trigger device trackers cleanup."""
|
||||
_LOGGER.debug("Device tracker cleanup triggered")
|
||||
|
@@ -62,9 +62,6 @@
|
||||
},
|
||||
"set_guest_wifi_password": {
|
||||
"service": "mdi:form-textbox-password"
|
||||
},
|
||||
"dial": {
|
||||
"service": "mdi:phone-dial"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -5,7 +5,6 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["network"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/fritz",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["fritzconnection"],
|
||||
"requirements": ["fritzconnection[qr]==1.15.0", "xmltodict==0.13.0"],
|
||||
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import NotRequired, TypedDict
|
||||
from typing import TypedDict
|
||||
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
@@ -55,7 +55,7 @@ HostAttributes = TypedDict(
|
||||
"X_AVM-DE_Guest": bool,
|
||||
"X_AVM-DE_RequestClient": str,
|
||||
"X_AVM-DE_VPN": bool,
|
||||
"X_AVM-DE_WANAccess": NotRequired[str],
|
||||
"X_AVM-DE_WANAccess": str,
|
||||
"X_AVM-DE_Disallow": bool,
|
||||
"X_AVM-DE_IsMeshable": str,
|
||||
"X_AVM-DE_Priority": str,
|
||||
|
@@ -4,7 +4,6 @@ import logging
|
||||
|
||||
from fritzconnection.core.exceptions import (
|
||||
FritzActionError,
|
||||
FritzActionFailedError,
|
||||
FritzConnectionException,
|
||||
FritzServiceError,
|
||||
)
|
||||
@@ -28,14 +27,6 @@ SERVICE_SCHEMA_SET_GUEST_WIFI_PW = vol.Schema(
|
||||
vol.Optional("length"): vol.Range(min=8, max=63),
|
||||
}
|
||||
)
|
||||
SERVICE_DIAL = "dial"
|
||||
SERVICE_SCHEMA_DIAL = vol.Schema(
|
||||
{
|
||||
vol.Required("device_id"): str,
|
||||
vol.Required("number"): str,
|
||||
vol.Required("max_ring_seconds"): vol.Range(min=1, max=300),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def _async_set_guest_wifi_password(service_call: ServiceCall) -> None:
|
||||
@@ -74,46 +65,6 @@ async def _async_set_guest_wifi_password(service_call: ServiceCall) -> None:
|
||||
) from ex
|
||||
|
||||
|
||||
async def _async_dial(service_call: ServiceCall) -> None:
|
||||
"""Call Fritz dial service."""
|
||||
target_entry_ids = await async_extract_config_entry_ids(service_call)
|
||||
target_entries: list[FritzConfigEntry] = [
|
||||
loaded_entry
|
||||
for loaded_entry in service_call.hass.config_entries.async_loaded_entries(
|
||||
DOMAIN
|
||||
)
|
||||
if loaded_entry.entry_id in target_entry_ids
|
||||
]
|
||||
|
||||
if not target_entries:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_not_found",
|
||||
translation_placeholders={"service": service_call.service},
|
||||
)
|
||||
|
||||
for target_entry in target_entries:
|
||||
_LOGGER.debug("Executing service %s", service_call.service)
|
||||
avm_wrapper = target_entry.runtime_data
|
||||
try:
|
||||
await avm_wrapper.async_trigger_dial(
|
||||
service_call.data["number"],
|
||||
max_ring_seconds=service_call.data["max_ring_seconds"],
|
||||
)
|
||||
except (FritzServiceError, FritzActionError) as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="service_parameter_unknown"
|
||||
) from ex
|
||||
except FritzActionFailedError as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="service_dial_failed"
|
||||
) from ex
|
||||
except FritzConnectionException as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="service_not_supported"
|
||||
) from ex
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up services for Fritz integration."""
|
||||
@@ -124,4 +75,3 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
_async_set_guest_wifi_password,
|
||||
SERVICE_SCHEMA_SET_GUEST_WIFI_PW,
|
||||
)
|
||||
hass.services.async_register(DOMAIN, SERVICE_DIAL, _async_dial, SERVICE_SCHEMA_DIAL)
|
||||
|
@@ -17,24 +17,3 @@ set_guest_wifi_password:
|
||||
number:
|
||||
min: 8
|
||||
max: 63
|
||||
dial:
|
||||
fields:
|
||||
device_id:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: fritz
|
||||
entity:
|
||||
device_class: connectivity
|
||||
number:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
max_ring_seconds:
|
||||
default: 15
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 300
|
||||
unit_of_measurement: seconds
|
||||
|
@@ -198,33 +198,12 @@
|
||||
"description": "Length of the new password. It will be auto-generated if no password is set."
|
||||
}
|
||||
}
|
||||
},
|
||||
"dial": {
|
||||
"name": "Dial a phone number",
|
||||
"description": "Makes the FRITZ!Box dial a phone number.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "FRITZ!Box device",
|
||||
"description": "Select the FRITZ!Box to dial from."
|
||||
},
|
||||
"number": {
|
||||
"name": "Phone number",
|
||||
"description": "The phone number to dial."
|
||||
},
|
||||
"max_ring_seconds": {
|
||||
"name": "Maximum ring duration",
|
||||
"description": "The maximum number of seconds to ring after dialing."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"config_entry_not_found": {
|
||||
"message": "Failed to perform action \"{service}\". Config entry for target not found"
|
||||
},
|
||||
"service_dial_failed": {
|
||||
"message": "Failed to dial, check if the click to dial service of the FRITZ!Box is activated"
|
||||
},
|
||||
"service_parameter_unknown": {
|
||||
"message": "Action or parameter unknown"
|
||||
},
|
||||
|
@@ -744,7 +744,9 @@ class ManifestJSONView(HomeAssistantView):
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
"type": "frontend/get_icons",
|
||||
vol.Required("category"): vol.In({"entity", "entity_component", "services"}),
|
||||
vol.Required("category"): vol.In(
|
||||
{"entity", "entity_component", "services", "triggers"}
|
||||
),
|
||||
vol.Optional("integration"): vol.All(cv.ensure_list, [str]),
|
||||
}
|
||||
)
|
||||
|
@@ -128,7 +128,7 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self._abort_if_unique_id_configured()
|
||||
return await self.async_step_confirm()
|
||||
|
||||
current_addresses = self._async_current_ids(include_ignore=False)
|
||||
current_addresses = self._async_current_ids()
|
||||
for discovery_info in async_discovered_service_info(self.hass):
|
||||
address = discovery_info.address
|
||||
if address in current_addresses or not _is_supported(discovery_info):
|
||||
|
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/generic",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["av==13.1.0", "Pillow==12.0.0"]
|
||||
"requirements": ["av==13.1.0", "Pillow==11.3.0"]
|
||||
}
|
||||
|
@@ -30,8 +30,8 @@ from homeassistant.components.camera import (
|
||||
WebRTCMessage,
|
||||
WebRTCSendMessage,
|
||||
async_register_webrtc_provider,
|
||||
get_dynamic_camera_stream_settings,
|
||||
)
|
||||
from homeassistant.components.camera.prefs import get_dynamic_camera_stream_settings
|
||||
from homeassistant.components.default_config import DOMAIN as DEFAULT_CONFIG_DOMAIN
|
||||
from homeassistant.components.stream import Orientation
|
||||
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
|
||||
|
@@ -55,7 +55,7 @@ from homeassistant.helpers.entity_platform import (
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from .entity import GroupEntity
|
||||
from .util import find_state_attributes, mean_circle, mean_tuple, reduce_attribute
|
||||
from .util import find_state_attributes, mean_tuple, reduce_attribute
|
||||
|
||||
DEFAULT_NAME = "Light Group"
|
||||
CONF_ALL = "all"
|
||||
@@ -229,7 +229,7 @@ class LightGroup(GroupEntity, LightEntity):
|
||||
self._attr_brightness = reduce_attribute(on_states, ATTR_BRIGHTNESS)
|
||||
|
||||
self._attr_hs_color = reduce_attribute(
|
||||
on_states, ATTR_HS_COLOR, reduce=mean_circle
|
||||
on_states, ATTR_HS_COLOR, reduce=mean_tuple
|
||||
)
|
||||
self._attr_rgb_color = reduce_attribute(
|
||||
on_states, ATTR_RGB_COLOR, reduce=mean_tuple
|
||||
|
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterator
|
||||
from itertools import groupby
|
||||
from math import atan2, cos, degrees, radians, sin
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import State
|
||||
@@ -33,23 +32,6 @@ def mean_tuple(*args: Any) -> tuple[float | Any, ...]:
|
||||
return tuple(sum(x) / len(x) for x in zip(*args, strict=False))
|
||||
|
||||
|
||||
def mean_circle(*args: Any) -> tuple[float | Any, ...]:
|
||||
"""Return the circular mean of hue values and arithmetic mean of saturation values from HS color tuples."""
|
||||
if not args:
|
||||
return ()
|
||||
|
||||
hues, saturations = zip(*args, strict=False)
|
||||
|
||||
sum_x = sum(cos(radians(h)) for h in hues)
|
||||
sum_y = sum(sin(radians(h)) for h in hues)
|
||||
|
||||
mean_angle = degrees(atan2(sum_y, sum_x)) % 360
|
||||
|
||||
saturation = sum(saturations) / len(saturations)
|
||||
|
||||
return (mean_angle, saturation)
|
||||
|
||||
|
||||
def attribute_equal(states: list[State], key: str) -> bool:
|
||||
"""Return True if all attributes found matching key from states are equal.
|
||||
|
||||
|
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "growatt_server",
|
||||
"name": "Growatt",
|
||||
"codeowners": ["@johanzander"],
|
||||
"codeowners": [],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/growatt_server",
|
||||
"iot_class": "cloud_polling",
|
||||
|
@@ -72,7 +72,8 @@ async def async_setup_entry(
|
||||
config_entry.runtime_data = coordinator
|
||||
|
||||
party = coordinator.data.user.party.id
|
||||
hass.data.setdefault(HABITICA_KEY, {})
|
||||
if HABITICA_KEY not in hass.data:
|
||||
hass.data[HABITICA_KEY] = {}
|
||||
|
||||
if party is not None and party not in hass.data[HABITICA_KEY]:
|
||||
party_coordinator = HabiticaPartyCoordinator(hass, config_entry, api)
|
||||
@@ -116,20 +117,9 @@ async def async_setup_entry(
|
||||
coordinator.async_add_listener(_party_update_listener)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||
|
||||
config_entry.async_on_unload(
|
||||
config_entry.add_update_listener(_async_update_listener)
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def _async_update_listener(
|
||||
hass: HomeAssistant, entry: HabiticaConfigEntry
|
||||
) -> None:
|
||||
"""Handle update."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def shutdown_party_coordinator(hass: HomeAssistant, party_added: UUID) -> None:
|
||||
"""Handle party coordinator shutdown."""
|
||||
await hass.data[HABITICA_KEY][party_added].async_shutdown()
|
||||
|
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from uuid import UUID
|
||||
|
||||
from aiohttp import ClientError
|
||||
from habiticalib import (
|
||||
@@ -18,14 +17,7 @@ from habiticalib import (
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigEntryState,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
ConfigSubentryFlow,
|
||||
SubentryFlowResult,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_NAME,
|
||||
@@ -34,21 +26,15 @@ from homeassistant.const import (
|
||||
CONF_USERNAME,
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
TextSelector,
|
||||
TextSelectorConfig,
|
||||
TextSelectorType,
|
||||
)
|
||||
|
||||
from . import HABITICA_KEY
|
||||
from .const import (
|
||||
CONF_API_USER,
|
||||
CONF_PARTY_MEMBER,
|
||||
DEFAULT_URL,
|
||||
DOMAIN,
|
||||
FORGOT_PASSWORD_URL,
|
||||
@@ -388,66 +374,3 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return errors, user.data
|
||||
|
||||
return errors, None
|
||||
|
||||
@classmethod
|
||||
@callback
|
||||
def async_get_supported_subentry_types(
|
||||
cls, config_entry: ConfigEntry
|
||||
) -> dict[str, type[ConfigSubentryFlow]]:
|
||||
"""Return subentries supported by this integration."""
|
||||
return {"party_member": PartyMembersSubentryFlowHandler}
|
||||
|
||||
|
||||
class PartyMembersSubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Handle subentry flow for adding party members."""
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Subentry user flow."""
|
||||
|
||||
entry: HabiticaConfigEntry = self._get_entry()
|
||||
if entry.state is not ConfigEntryState.LOADED:
|
||||
return self.async_abort(reason="config_entry_disabled")
|
||||
if (party := entry.runtime_data.data.user.party.id) is None:
|
||||
return self.async_abort(reason="not_in_a_party")
|
||||
|
||||
party_members = self.hass.data[HABITICA_KEY][party].data.members
|
||||
|
||||
if user_input is not None:
|
||||
config_entries = self.hass.config_entries.async_entries(DOMAIN)
|
||||
|
||||
for entry in config_entries:
|
||||
if user_input[CONF_PARTY_MEMBER] == entry.unique_id:
|
||||
return self.async_abort(reason="already_configured_as_entry")
|
||||
if user_input[CONF_PARTY_MEMBER] in {
|
||||
subentry.unique_id for subentry in entry.subentries.values()
|
||||
}:
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
return self.async_create_entry(
|
||||
title=party_members[UUID(user_input[CONF_PARTY_MEMBER])].profile.name,
|
||||
data={},
|
||||
unique_id=user_input[CONF_PARTY_MEMBER],
|
||||
)
|
||||
|
||||
options = [
|
||||
SelectOptionDict(
|
||||
value=str(member_id),
|
||||
label=f"{member.profile.name} (@{member.auth.local.username})",
|
||||
)
|
||||
for member_id, member in party_members.items()
|
||||
if member_id != str(entry.runtime_data.data.user.id)
|
||||
and member.profile.name
|
||||
and member.auth.local.username
|
||||
]
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PARTY_MEMBER): SelectSelector(
|
||||
SelectSelectorConfig(options=options)
|
||||
)
|
||||
}
|
||||
),
|
||||
)
|
||||
|
@@ -3,7 +3,6 @@
|
||||
from homeassistant.const import APPLICATION_NAME, __version__
|
||||
|
||||
CONF_API_USER = "api_user"
|
||||
CONF_PARTY_MEMBER = "party_member"
|
||||
|
||||
DEFAULT_URL = "https://habitica.com"
|
||||
ASSETS_URL = "https://habitica-assets.s3.amazonaws.com/mobileApp/images/"
|
||||
|
@@ -213,9 +213,7 @@ class HabiticaPartyCoordinator(HabiticaBaseCoordinator[HabiticaPartyData]):
|
||||
party=(await self.habitica.get_group()).data,
|
||||
members={
|
||||
member.id: member
|
||||
for member in (
|
||||
await self.habitica.get_group_members(public_fields=True)
|
||||
).data
|
||||
for member in (await self.habitica.get_group_members()).data
|
||||
if member.id
|
||||
},
|
||||
)
|
||||
|
@@ -3,12 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
from habiticalib import ContentData, UserData
|
||||
from habiticalib import ContentData
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.const import CONF_URL
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
@@ -31,84 +29,26 @@ class HabiticaBase(CoordinatorEntity[HabiticaDataUpdateCoordinator]):
|
||||
self,
|
||||
coordinator: HabiticaDataUpdateCoordinator,
|
||||
entity_description: EntityDescription,
|
||||
subentry: ConfigSubentry | None = None,
|
||||
) -> None:
|
||||
"""Initialize a Habitica entity."""
|
||||
super().__init__(coordinator)
|
||||
if TYPE_CHECKING:
|
||||
assert coordinator.config_entry.unique_id
|
||||
assert self.user
|
||||
self.entity_description = entity_description
|
||||
self.subentry = subentry
|
||||
unique_id = (
|
||||
subentry.unique_id
|
||||
if subentry is not None and subentry.unique_id
|
||||
else coordinator.config_entry.unique_id
|
||||
self._attr_unique_id = (
|
||||
f"{coordinator.config_entry.unique_id}_{entity_description.key}"
|
||||
)
|
||||
|
||||
self._attr_unique_id = f"{unique_id}_{entity_description.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
manufacturer=MANUFACTURER,
|
||||
model=NAME,
|
||||
name=self.user.profile.name,
|
||||
name=coordinator.data.user.profile.name,
|
||||
configuration_url=(
|
||||
URL(coordinator.config_entry.data[CONF_URL]) / "profile" / unique_id
|
||||
URL(coordinator.config_entry.data[CONF_URL])
|
||||
/ "profile"
|
||||
/ coordinator.config_entry.unique_id
|
||||
),
|
||||
identifiers={(DOMAIN, unique_id)},
|
||||
)
|
||||
|
||||
if subentry:
|
||||
self._attr_device_info.update(
|
||||
DeviceInfo(
|
||||
via_device=(
|
||||
(
|
||||
DOMAIN,
|
||||
f"{coordinator.config_entry.unique_id}_{self.user.party.id}",
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def user(self) -> UserData | None:
|
||||
"""Return the user data."""
|
||||
return self.coordinator.data.user
|
||||
|
||||
|
||||
class HabiticaPartyMemberBase(HabiticaBase):
|
||||
"""Base Habitica party member entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HabiticaDataUpdateCoordinator,
|
||||
party_coordinator: HabiticaPartyCoordinator,
|
||||
entity_description: EntityDescription,
|
||||
subentry: ConfigSubentry | None = None,
|
||||
) -> None:
|
||||
"""Initialize a Habitica entity."""
|
||||
self.party_coordinator = party_coordinator
|
||||
super().__init__(coordinator, entity_description, subentry)
|
||||
|
||||
@property
|
||||
def user(self) -> UserData | None:
|
||||
"""Return the user data of the party member."""
|
||||
if TYPE_CHECKING:
|
||||
assert self.subentry
|
||||
assert self.subentry.unique_id
|
||||
return self.party_coordinator.data.members.get(UUID(self.subentry.unique_id))
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
|
||||
return super().available and self.user is not None
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
self.async_on_remove(
|
||||
self.party_coordinator.async_add_listener(self._handle_coordinator_update)
|
||||
identifiers={(DOMAIN, coordinator.config_entry.unique_id)},
|
||||
)
|
||||
|
||||
|
||||
|
@@ -3,13 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
from habiticalib import Avatar, ContentData, extract_avatar
|
||||
|
||||
from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
@@ -21,7 +18,7 @@ from .coordinator import (
|
||||
HabiticaDataUpdateCoordinator,
|
||||
HabiticaPartyCoordinator,
|
||||
)
|
||||
from .entity import HabiticaBase, HabiticaPartyBase, HabiticaPartyMemberBase
|
||||
from .entity import HabiticaBase, HabiticaPartyBase
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
@@ -50,22 +47,6 @@ async def async_setup_entry(
|
||||
hass, party_coordinator, config_entry, coordinator.content
|
||||
)
|
||||
)
|
||||
for subentry_id, subentry in config_entry.subentries.items():
|
||||
if (
|
||||
subentry.unique_id
|
||||
and UUID(subentry.unique_id) in party_coordinator.data.members
|
||||
):
|
||||
async_add_entities(
|
||||
[
|
||||
HabiticaPartyMemberImage(
|
||||
hass,
|
||||
coordinator,
|
||||
party_coordinator,
|
||||
subentry,
|
||||
)
|
||||
],
|
||||
config_subentry_id=subentry_id,
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -85,21 +66,18 @@ class HabiticaImage(HabiticaBase, ImageEntity):
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
coordinator: HabiticaDataUpdateCoordinator,
|
||||
subentry: ConfigSubentry | None = None,
|
||||
) -> None:
|
||||
"""Initialize the image entity."""
|
||||
HabiticaBase.__init__(self, coordinator, self.entity_description, subentry)
|
||||
super().__init__(coordinator, self.entity_description)
|
||||
ImageEntity.__init__(self, hass)
|
||||
self._attr_image_last_updated = dt_util.utcnow()
|
||||
if TYPE_CHECKING:
|
||||
assert self.user
|
||||
self._avatar = extract_avatar(self.user)
|
||||
self._avatar = extract_avatar(self.coordinator.data.user)
|
||||
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Check if equipped gear and other things have changed since last avatar image generation."""
|
||||
|
||||
if self.user is not None and self._avatar != self.user:
|
||||
self._avatar = extract_avatar(self.user)
|
||||
if self._avatar != self.coordinator.data.user:
|
||||
self._avatar = extract_avatar(self.coordinator.data.user)
|
||||
self._attr_image_last_updated = dt_util.utcnow()
|
||||
self._cache = None
|
||||
|
||||
@@ -112,24 +90,6 @@ class HabiticaImage(HabiticaBase, ImageEntity):
|
||||
return self._cache
|
||||
|
||||
|
||||
class HabiticaPartyMemberImage(HabiticaImage, HabiticaPartyMemberBase):
|
||||
"""A Habitica party member image entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
coordinator: HabiticaDataUpdateCoordinator,
|
||||
party_coordinator: HabiticaPartyCoordinator,
|
||||
subentry: ConfigSubentry | None = None,
|
||||
) -> None:
|
||||
"""Initialize the image entity."""
|
||||
|
||||
HabiticaPartyMemberBase.__init__(
|
||||
self, coordinator, party_coordinator, self.entity_description, subentry
|
||||
)
|
||||
super().__init__(hass, coordinator, subentry)
|
||||
|
||||
|
||||
class HabiticaPartyImage(HabiticaPartyBase, ImageEntity):
|
||||
"""A Habitica image entity of a party."""
|
||||
|
||||
|
@@ -4,9 +4,8 @@
|
||||
"codeowners": ["@tr4nt0r"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/habitica",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["habiticalib"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["habiticalib==0.4.6"]
|
||||
"requirements": ["habiticalib==0.4.5"]
|
||||
}
|
||||
|
@@ -68,8 +68,8 @@ rules:
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: Integration has no repairs
|
||||
status: done
|
||||
comment: Used to inform of deprecated entities and actions.
|
||||
stale-devices:
|
||||
status: done
|
||||
comment: Party device is remove if stale.
|
||||
|
@@ -8,7 +8,6 @@ from datetime import datetime
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from habiticalib import ContentData, GroupData, HabiticaClass, TaskData, UserData, ha
|
||||
|
||||
@@ -25,7 +24,7 @@ from homeassistant.util import dt as dt_util
|
||||
from . import HABITICA_KEY
|
||||
from .const import ASSETS_URL
|
||||
from .coordinator import HabiticaConfigEntry
|
||||
from .entity import HabiticaBase, HabiticaPartyBase, HabiticaPartyMemberBase
|
||||
from .entity import HabiticaBase, HabiticaPartyBase
|
||||
from .util import (
|
||||
collected_quest_items,
|
||||
get_attribute_points,
|
||||
@@ -119,13 +118,12 @@ class HabiticaSensorEntity(StrEnum):
|
||||
LAST_CHECKIN = "last_checkin"
|
||||
|
||||
|
||||
SENSOR_DESCRIPTIONS_COMMON: tuple[HabiticaSensorEntityDescription, ...] = (
|
||||
SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = (
|
||||
HabiticaSensorEntityDescription(
|
||||
key=HabiticaSensorEntity.DISPLAY_NAME,
|
||||
translation_key=HabiticaSensorEntity.DISPLAY_NAME,
|
||||
value_fn=lambda user, _: user.profile.name,
|
||||
attributes_fn=lambda user, _: {
|
||||
"username": f"@{user.auth.local.username}",
|
||||
"blurb": user.profile.blurb,
|
||||
"joined": (
|
||||
dt_util.as_local(joined).date()
|
||||
@@ -177,6 +175,13 @@ SENSOR_DESCRIPTIONS_COMMON: tuple[HabiticaSensorEntityDescription, ...] = (
|
||||
translation_key=HabiticaSensorEntity.LEVEL,
|
||||
value_fn=lambda user, _: user.stats.lvl,
|
||||
),
|
||||
HabiticaSensorEntityDescription(
|
||||
key=HabiticaSensorEntity.GOLD,
|
||||
translation_key=HabiticaSensorEntity.GOLD,
|
||||
suggested_display_precision=2,
|
||||
value_fn=lambda user, _: user.stats.gp,
|
||||
entity_picture=ha.GP,
|
||||
),
|
||||
HabiticaSensorEntityDescription(
|
||||
key=HabiticaSensorEntity.CLASS,
|
||||
translation_key=HabiticaSensorEntity.CLASS,
|
||||
@@ -184,6 +189,21 @@ SENSOR_DESCRIPTIONS_COMMON: tuple[HabiticaSensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[item.value for item in HabiticaClass],
|
||||
),
|
||||
HabiticaSensorEntityDescription(
|
||||
key=HabiticaSensorEntity.GEMS,
|
||||
translation_key=HabiticaSensorEntity.GEMS,
|
||||
value_fn=lambda user, _: None if (b := user.balance) is None else round(b * 4),
|
||||
suggested_display_precision=0,
|
||||
entity_picture="shop_gem.png",
|
||||
),
|
||||
HabiticaSensorEntityDescription(
|
||||
key=HabiticaSensorEntity.TRINKETS,
|
||||
translation_key=HabiticaSensorEntity.TRINKETS,
|
||||
value_fn=lambda user, _: user.purchased.plan.consecutive.trinkets,
|
||||
suggested_display_precision=0,
|
||||
native_unit_of_measurement="⧖",
|
||||
entity_picture="notif_subscriber_reward.png",
|
||||
),
|
||||
HabiticaSensorEntityDescription(
|
||||
key=HabiticaSensorEntity.STRENGTH,
|
||||
translation_key=HabiticaSensorEntity.STRENGTH,
|
||||
@@ -216,40 +236,6 @@ SENSOR_DESCRIPTIONS_COMMON: tuple[HabiticaSensorEntityDescription, ...] = (
|
||||
suggested_display_precision=0,
|
||||
native_unit_of_measurement="CON",
|
||||
),
|
||||
HabiticaSensorEntityDescription(
|
||||
key=HabiticaSensorEntity.LAST_CHECKIN,
|
||||
translation_key=HabiticaSensorEntity.LAST_CHECKIN,
|
||||
value_fn=(
|
||||
lambda user, _: dt_util.as_local(last)
|
||||
if (last := user.auth.timestamps.loggedin)
|
||||
else None
|
||||
),
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
),
|
||||
)
|
||||
SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = (
|
||||
HabiticaSensorEntityDescription(
|
||||
key=HabiticaSensorEntity.GOLD,
|
||||
translation_key=HabiticaSensorEntity.GOLD,
|
||||
suggested_display_precision=2,
|
||||
value_fn=lambda user, _: user.stats.gp,
|
||||
entity_picture=ha.GP,
|
||||
),
|
||||
HabiticaSensorEntityDescription(
|
||||
key=HabiticaSensorEntity.GEMS,
|
||||
translation_key=HabiticaSensorEntity.GEMS,
|
||||
value_fn=lambda user, _: None if (b := user.balance) is None else round(b * 4),
|
||||
suggested_display_precision=0,
|
||||
entity_picture="shop_gem.png",
|
||||
),
|
||||
HabiticaSensorEntityDescription(
|
||||
key=HabiticaSensorEntity.TRINKETS,
|
||||
translation_key=HabiticaSensorEntity.TRINKETS,
|
||||
value_fn=lambda user, _: user.purchased.plan.consecutive.trinkets,
|
||||
suggested_display_precision=0,
|
||||
native_unit_of_measurement="⧖",
|
||||
entity_picture="notif_subscriber_reward.png",
|
||||
),
|
||||
HabiticaSensorEntityDescription(
|
||||
key=HabiticaSensorEntity.EGGS_TOTAL,
|
||||
translation_key=HabiticaSensorEntity.EGGS_TOTAL,
|
||||
@@ -300,6 +286,16 @@ SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = (
|
||||
translation_key=HabiticaSensorEntity.PENDING_QUEST_ITEMS,
|
||||
value_fn=pending_quest_items,
|
||||
),
|
||||
HabiticaSensorEntityDescription(
|
||||
key=HabiticaSensorEntity.LAST_CHECKIN,
|
||||
translation_key=HabiticaSensorEntity.LAST_CHECKIN,
|
||||
value_fn=(
|
||||
lambda user, _: dt_util.as_local(last)
|
||||
if (last := user.auth.timestamps.loggedin)
|
||||
else None
|
||||
),
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -393,8 +389,7 @@ async def async_setup_entry(
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
HabiticaSensor(coordinator, description)
|
||||
for description in SENSOR_DESCRIPTIONS + SENSOR_DESCRIPTIONS_COMMON
|
||||
HabiticaSensor(coordinator, description) for description in SENSOR_DESCRIPTIONS
|
||||
)
|
||||
|
||||
if party := coordinator.data.user.party.id:
|
||||
@@ -408,23 +403,6 @@ async def async_setup_entry(
|
||||
)
|
||||
for description in SENSOR_DESCRIPTIONS_PARTY
|
||||
)
|
||||
for subentry_id, subentry in config_entry.subentries.items():
|
||||
if (
|
||||
subentry.unique_id
|
||||
and UUID(subentry.unique_id) in party_coordinator.data.members
|
||||
):
|
||||
async_add_entities(
|
||||
[
|
||||
HabiticaPartyMemberSensor(
|
||||
coordinator,
|
||||
party_coordinator,
|
||||
description,
|
||||
subentry,
|
||||
)
|
||||
for description in SENSOR_DESCRIPTIONS_COMMON
|
||||
],
|
||||
config_subentry_id=subentry_id,
|
||||
)
|
||||
|
||||
|
||||
class HabiticaSensor(HabiticaBase, SensorEntity):
|
||||
@@ -436,33 +414,27 @@ class HabiticaSensor(HabiticaBase, SensorEntity):
|
||||
def native_value(self) -> StateType | datetime:
|
||||
"""Return the state of the device."""
|
||||
|
||||
return (
|
||||
self.entity_description.value_fn(self.user, self.coordinator.content)
|
||||
if self.user is not None
|
||||
else None
|
||||
return self.entity_description.value_fn(
|
||||
self.coordinator.data.user, self.coordinator.content
|
||||
)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, float | None] | None:
|
||||
"""Return entity specific state attributes."""
|
||||
if self.user is not None and (func := self.entity_description.attributes_fn):
|
||||
return func(self.user, self.coordinator.content)
|
||||
if func := self.entity_description.attributes_fn:
|
||||
return func(self.coordinator.data.user, self.coordinator.content)
|
||||
return None
|
||||
|
||||
@property
|
||||
def entity_picture(self) -> str | None:
|
||||
"""Return the entity picture to use in the frontend, if any."""
|
||||
if (
|
||||
self.entity_description.key is HabiticaSensorEntity.CLASS
|
||||
and self.user is not None
|
||||
and (_class := self.user.stats.Class)
|
||||
if self.entity_description.key is HabiticaSensorEntity.CLASS and (
|
||||
_class := self.coordinator.data.user.stats.Class
|
||||
):
|
||||
return SVG_CLASS[_class]
|
||||
|
||||
if (
|
||||
self.entity_description.key is HabiticaSensorEntity.DISPLAY_NAME
|
||||
and self.user is not None
|
||||
and (img_url := self.user.profile.imageUrl)
|
||||
if self.entity_description.key is HabiticaSensorEntity.DISPLAY_NAME and (
|
||||
img_url := self.coordinator.data.user.profile.imageUrl
|
||||
):
|
||||
return img_url
|
||||
|
||||
@@ -476,10 +448,6 @@ class HabiticaSensor(HabiticaBase, SensorEntity):
|
||||
return None
|
||||
|
||||
|
||||
class HabiticaPartyMemberSensor(HabiticaSensor, HabiticaPartyMemberBase):
|
||||
"""Habitica party member sensor."""
|
||||
|
||||
|
||||
class HabiticaPartySensor(HabiticaPartyBase, SensorEntity):
|
||||
"""Habitica party sensor."""
|
||||
|
||||
|
@@ -174,32 +174,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"config_subentries": {
|
||||
"party_member": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Party members",
|
||||
"description": "Track the stats of the adventurers in your party.",
|
||||
"data": {
|
||||
"party_member": "Party member"
|
||||
},
|
||||
"data_description": {
|
||||
"party_member": "Select an adventurer from your party to track health and other stats."
|
||||
}
|
||||
}
|
||||
},
|
||||
"initiate_flow": {
|
||||
"user": "Add party member"
|
||||
},
|
||||
"entry_type": "Party member",
|
||||
"abort": {
|
||||
"already_configured_as_entry": "Already configured as a user. This adventurer cannot be added as a party member.",
|
||||
"already_configured": "This adventurer is already configured as a party member in this or another account.",
|
||||
"config_entry_disabled": "Cannot add party members when the main account is disabled or not loaded.",
|
||||
"not_in_a_party": "You are currently not in a party. You can only add party members when your character is in a party."
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"pending_quest": {
|
||||
@@ -313,9 +287,6 @@
|
||||
},
|
||||
"total_logins": {
|
||||
"name": "Total logins"
|
||||
},
|
||||
"username": {
|
||||
"name": "[%key:common::config_flow::data::username%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -620,6 +591,12 @@
|
||||
"message": "Unable to send message, {name} not found. ({reason})"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_entity": {
|
||||
"title": "The Habitica {name} entity is deprecated",
|
||||
"description": "The Habitica entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your automations and scripts, disable `{entity}` and reload the integration/restart Home Assistant to fix this issue."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"cast_skill": {
|
||||
"name": "Cast a skill",
|
||||
|
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.hardware import BoardInfo, HardwareInfo
|
||||
from homeassistant.components.hardware.models import BoardInfo, HardwareInfo
|
||||
from homeassistant.components.hassio import get_os_info
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
@@ -11,13 +11,7 @@ from homeassistant.helpers.typing import ConfigType
|
||||
from . import websocket_api
|
||||
from .const import DATA_HARDWARE, DOMAIN
|
||||
from .hardware import async_process_hardware_platforms
|
||||
from .models import BoardInfo, HardwareData, HardwareInfo, SystemStatus, USBInfo
|
||||
|
||||
__all__ = [
|
||||
"BoardInfo",
|
||||
"HardwareInfo",
|
||||
"USBInfo",
|
||||
]
|
||||
from .models import HardwareData, SystemStatus
|
||||
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
|
@@ -1,20 +1,15 @@
|
||||
"""The Logitech Harmony Hub integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from homeassistant.components.remote import ATTR_ACTIVITY, ATTR_DELAY_SECS
|
||||
from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
|
||||
if sys.version_info < (3, 14):
|
||||
from .const import HARMONY_OPTIONS_UPDATE, PLATFORMS
|
||||
from .data import HarmonyConfigEntry, HarmonyData
|
||||
from .const import HARMONY_OPTIONS_UPDATE, PLATFORMS
|
||||
from .data import HarmonyConfigEntry, HarmonyData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,10 +20,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: HarmonyConfigEntry) -> b
|
||||
# when setting up a config entry, we fallback to adding
|
||||
# the options to the config entry and pull them out here if
|
||||
# they are missing from the options
|
||||
if sys.version_info >= (3, 14):
|
||||
raise HomeAssistantError(
|
||||
"Logitech Harmony Hub is not supported on Python 3.14. Please use Python 3.13."
|
||||
)
|
||||
_async_import_options_from_data_if_missing(hass, entry)
|
||||
|
||||
address = entry.data[CONF_HOST]
|
||||
|
@@ -7,7 +7,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/harmony",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aioharmony", "slixmpp"],
|
||||
"requirements": ["aioharmony==0.5.3;python_version<'3.14'"],
|
||||
"requirements": ["aioharmony==0.5.3"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Logitech",
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user