mirror of
https://github.com/home-assistant/core.git
synced 2025-11-04 16:39:28 +00:00
Compare commits
3 Commits
chat-log-s
...
cdce8p-bui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2140bd033 | ||
|
|
133054693e | ||
|
|
f695fa182c |
@@ -33,7 +33,7 @@
|
||||
"GitHub.vscode-pull-request-github",
|
||||
"GitHub.copilot"
|
||||
],
|
||||
// Please keep this file in sync with settings in home-assistant/.vscode/settings.default.jsonc
|
||||
// Please keep this file in sync with settings in home-assistant/.vscode/settings.default.json
|
||||
"settings": {
|
||||
"python.experiments.optOutFrom": ["pythonTestAdapter"],
|
||||
"python.defaultInterpreterPath": "/home/vscode/.local/ha-venv/bin/python",
|
||||
@@ -41,7 +41,6 @@
|
||||
"python.terminal.activateEnvInCurrentTerminal": true,
|
||||
"python.testing.pytestArgs": ["--no-cov"],
|
||||
"pylint.importStrategy": "fromEnvironment",
|
||||
"python.analysis.typeCheckingMode": "basic",
|
||||
"editor.formatOnPaste": false,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnType": true,
|
||||
@@ -63,9 +62,6 @@
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "charliermarsh.ruff"
|
||||
},
|
||||
"[json][jsonc][yaml]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"json.schemas": [
|
||||
{
|
||||
"fileMatch": ["homeassistant/components/*/manifest.json"],
|
||||
|
||||
1
.github/copilot-instructions.md
vendored
1
.github/copilot-instructions.md
vendored
@@ -74,7 +74,6 @@ rules:
|
||||
- **Formatting**: Ruff
|
||||
- **Linting**: PyLint and Ruff
|
||||
- **Type Checking**: MyPy
|
||||
- **Lint/Type/Format Fixes**: Always prefer addressing the underlying issue (e.g., import the typed source, update shared stubs, align with Ruff expectations, or correct formatting at the source) before disabling a rule, adding `# type: ignore`, or skipping a formatter. Treat suppressions and `noqa` comments as a last resort once no compliant fix exists
|
||||
- **Testing**: pytest with plain functions and fixtures
|
||||
- **Language**: American English for all code, comments, and documentation (use sentence case, including titles)
|
||||
|
||||
|
||||
6
.github/workflows/builder.yml
vendored
6
.github/workflows/builder.yml
vendored
@@ -69,7 +69,7 @@ jobs:
|
||||
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
|
||||
|
||||
- name: Upload translations
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: translations
|
||||
path: translations.tar.gz
|
||||
@@ -175,7 +175,7 @@ jobs:
|
||||
sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt
|
||||
|
||||
- name: Download translations
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: translations
|
||||
|
||||
@@ -464,7 +464,7 @@ jobs:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
- name: Download translations
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: translations
|
||||
|
||||
|
||||
6
.github/workflows/ci.yaml
vendored
6
.github/workflows/ci.yaml
vendored
@@ -37,7 +37,7 @@ on:
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
CACHE_VERSION: 1
|
||||
CACHE_VERSION: 9
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2025.11"
|
||||
@@ -535,7 +535,7 @@ jobs:
|
||||
python --version
|
||||
uv pip freeze >> pip_freeze.txt
|
||||
- name: Upload pip_freeze artifact
|
||||
uses: &actions-upload-artifact actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: &actions-upload-artifact actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: pip-freeze-${{ matrix.python-version }}
|
||||
path: pip_freeze.txt
|
||||
@@ -867,7 +867,7 @@ jobs:
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
|
||||
- name: Download pytest_buckets
|
||||
uses: &actions-download-artifact actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: &actions-download-artifact actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: pytest_buckets
|
||||
- &compile-english-translations
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
|
||||
uses: github/codeql-action/init@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4.30.8
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
|
||||
uses: github/codeql-action/analyze@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4.30.8
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
30
.github/workflows/wheels.yml
vendored
30
.github/workflows/wheels.yml
vendored
@@ -80,6 +80,8 @@ 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
|
||||
@@ -92,7 +94,7 @@ jobs:
|
||||
) > build_constraints.txt
|
||||
|
||||
- name: Upload env_file
|
||||
uses: &actions-upload-artifact actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: &actions-upload-artifact actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: env_file
|
||||
path: ./.env_file
|
||||
@@ -126,13 +128,13 @@ jobs:
|
||||
|
||||
core:
|
||||
name: Build Core wheels ${{ matrix.abi }} for ${{ matrix.arch }} (musllinux_1_2)
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
if: false && github.repository_owner == 'home-assistant'
|
||||
needs: init
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: &matrix-build
|
||||
abi: ["cp313", "cp314"]
|
||||
abi: ["cp314"]
|
||||
arch: ${{ fromJson(needs.init.outputs.architectures) }}
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
@@ -150,7 +152,7 @@ jobs:
|
||||
|
||||
- &download-env-file
|
||||
name: Download env_file
|
||||
uses: &actions-download-artifact actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: &actions-download-artifact actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: env_file
|
||||
|
||||
@@ -219,9 +221,29 @@ 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
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -111,7 +111,6 @@ virtualization/vagrant/config
|
||||
!.vscode/cSpell.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/settings.default.jsonc
|
||||
.env
|
||||
|
||||
# Windows Explorer
|
||||
@@ -141,5 +140,4 @@ pytest_buckets.txt
|
||||
|
||||
# AI tooling
|
||||
.claude/settings.local.json
|
||||
.serena/
|
||||
|
||||
|
||||
@@ -33,8 +33,8 @@ repos:
|
||||
rev: v1.37.1
|
||||
hooks:
|
||||
- id: yamllint
|
||||
- repo: https://github.com/rbubley/mirrors-prettier
|
||||
rev: v3.6.2
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: v3.0.3
|
||||
hooks:
|
||||
- id: prettier
|
||||
- repo: https://github.com/cdce8p/python-typing-update
|
||||
|
||||
@@ -278,7 +278,6 @@ homeassistant.components.imap.*
|
||||
homeassistant.components.imgw_pib.*
|
||||
homeassistant.components.immich.*
|
||||
homeassistant.components.incomfort.*
|
||||
homeassistant.components.inels.*
|
||||
homeassistant.components.input_button.*
|
||||
homeassistant.components.input_select.*
|
||||
homeassistant.components.input_text.*
|
||||
@@ -478,7 +477,6 @@ homeassistant.components.skybell.*
|
||||
homeassistant.components.slack.*
|
||||
homeassistant.components.sleep_as_android.*
|
||||
homeassistant.components.sleepiq.*
|
||||
homeassistant.components.sma.*
|
||||
homeassistant.components.smhi.*
|
||||
homeassistant.components.smlight.*
|
||||
homeassistant.components.smtp.*
|
||||
|
||||
@@ -7,19 +7,13 @@
|
||||
"python.testing.pytestEnabled": false,
|
||||
// https://code.visualstudio.com/docs/python/linting#_general-settings
|
||||
"pylint.importStrategy": "fromEnvironment",
|
||||
// Pyright is too pedantic for Home Assistant
|
||||
"python.analysis.typeCheckingMode": "basic",
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "charliermarsh.ruff",
|
||||
},
|
||||
"[json][jsonc][yaml]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
},
|
||||
"json.schemas": [
|
||||
{
|
||||
"fileMatch": ["homeassistant/components/*/manifest.json"],
|
||||
// This value differs between working with devcontainer and locally, therefore this value should NOT be in sync!
|
||||
"url": "./script/json_schemas/manifest_schema.json",
|
||||
},
|
||||
],
|
||||
{
|
||||
"fileMatch": [
|
||||
"homeassistant/components/*/manifest.json"
|
||||
],
|
||||
// This value differs between working with devcontainer and locally, therefor this value should NOT be in sync!
|
||||
"url": "./script/json_schemas/manifest_schema.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
CODEOWNERS
generated
6
CODEOWNERS
generated
@@ -494,8 +494,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/filesize/ @gjohansson-ST
|
||||
/homeassistant/components/filter/ @dgomes
|
||||
/tests/components/filter/ @dgomes
|
||||
/homeassistant/components/fing/ @Lorenzo-Gasparini
|
||||
/tests/components/fing/ @Lorenzo-Gasparini
|
||||
/homeassistant/components/firefly_iii/ @erwindouna
|
||||
/tests/components/firefly_iii/ @erwindouna
|
||||
/homeassistant/components/fireservicerota/ @cyberjunky
|
||||
@@ -743,8 +741,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/improv_ble/ @emontnemery
|
||||
/homeassistant/components/incomfort/ @jbouwh
|
||||
/tests/components/incomfort/ @jbouwh
|
||||
/homeassistant/components/inels/ @epdevlab
|
||||
/tests/components/inels/ @epdevlab
|
||||
/homeassistant/components/influxdb/ @mdegat01
|
||||
/tests/components/influxdb/ @mdegat01
|
||||
/homeassistant/components/inkbird/ @bdraco
|
||||
@@ -1543,8 +1539,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/suez_water/ @ooii @jb101010-2
|
||||
/homeassistant/components/sun/ @home-assistant/core
|
||||
/tests/components/sun/ @home-assistant/core
|
||||
/homeassistant/components/sunricher_dali_center/ @niracler
|
||||
/tests/components/sunricher_dali_center/ @niracler
|
||||
/homeassistant/components/supla/ @mwegrzynek
|
||||
/homeassistant/components/surepetcare/ @benleb @danielhiversen
|
||||
/tests/components/surepetcare/ @benleb @danielhiversen
|
||||
|
||||
4
Dockerfile
generated
4
Dockerfile
generated
@@ -25,13 +25,13 @@ RUN \
|
||||
"armv7") go2rtc_suffix='arm' ;; \
|
||||
*) go2rtc_suffix=${BUILD_ARCH} ;; \
|
||||
esac \
|
||||
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.11/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
|
||||
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.9/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
|
||||
&& chmod +x /bin/go2rtc \
|
||||
# Verify go2rtc can be executed
|
||||
&& go2rtc --version
|
||||
|
||||
# Install uv
|
||||
RUN pip3 install uv==0.9.5
|
||||
RUN pip3 install uv==0.8.9
|
||||
|
||||
WORKDIR /usr/src
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ build_from:
|
||||
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
|
||||
codenotary:
|
||||
signer: notary@home-assistant.io
|
||||
base_image: notary@home-assistant.io
|
||||
cosign:
|
||||
base_identity: https://github.com/home-assistant/docker/.*
|
||||
identity: https://github.com/home-assistant/core/.*
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["airos==0.6.0"]
|
||||
"requirements": ["airos==0.5.6"]
|
||||
}
|
||||
|
||||
@@ -11,5 +11,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airzone",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioairzone"],
|
||||
"requirements": ["aioairzone==1.0.2"]
|
||||
"requirements": ["aioairzone==1.0.1"]
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==6.4.6"]
|
||||
"requirements": ["aioamazondevices==6.4.4"]
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/aosmith",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["py-aosmith==1.0.15"]
|
||||
"requirements": ["py-aosmith==1.0.14"]
|
||||
}
|
||||
|
||||
@@ -109,12 +109,12 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if self.source == SOURCE_REAUTH:
|
||||
self._abort_if_unique_id_mismatch()
|
||||
return self.async_update_and_abort(
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(), data_updates=config
|
||||
)
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
self._abort_if_unique_id_mismatch()
|
||||
return self.async_update_and_abort(
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reconfigure_entry(), data_updates=config
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
@@ -248,7 +248,7 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
await self.async_set_unique_id(discovery_info[CONF_MAC])
|
||||
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={CONF_HOST: discovery_info[CONF_HOST]}, reload_on_update=False
|
||||
updates={CONF_HOST: discovery_info[CONF_HOST]}
|
||||
)
|
||||
|
||||
self.context.update(
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["bring_api"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["bring-api==1.1.1"]
|
||||
"requirements": ["bring-api==1.1.0"]
|
||||
}
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/bthome",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["bthome-ble==3.15.0"]
|
||||
"requirements": ["bthome-ble==3.14.2"]
|
||||
}
|
||||
|
||||
@@ -816,20 +816,13 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
|
||||
return MediaPlayerState.PAUSED
|
||||
if media_status.player_is_idle:
|
||||
return MediaPlayerState.IDLE
|
||||
|
||||
if self._chromecast is not None and self._chromecast.is_idle:
|
||||
# If library consider us idle, that is our off state
|
||||
# it takes HDMI status into account for cast devices.
|
||||
return MediaPlayerState.OFF
|
||||
|
||||
if self.app_id in APP_IDS_UNRELIABLE_MEDIA_INFO:
|
||||
# Some apps don't report media status, show the player as playing
|
||||
return MediaPlayerState.PLAYING
|
||||
|
||||
if self.app_id is not None:
|
||||
# We have an active app
|
||||
if self.app_id is not None and self.app_id != pychromecast.IDLE_APP_ID:
|
||||
if self.app_id in APP_IDS_UNRELIABLE_MEDIA_INFO:
|
||||
# Some apps don't report media status, show the player as playing
|
||||
return MediaPlayerState.PLAYING
|
||||
return MediaPlayerState.IDLE
|
||||
|
||||
if self._chromecast is not None and self._chromecast.is_idle:
|
||||
return MediaPlayerState.OFF
|
||||
return None
|
||||
|
||||
@property
|
||||
|
||||
@@ -14,7 +14,6 @@ from homeassistant.const import CONF_HOST, CONF_PORT
|
||||
from .const import DEFAULT_PORT, DOMAIN
|
||||
from .errors import (
|
||||
ConnectionRefused,
|
||||
ConnectionReset,
|
||||
ConnectionTimeout,
|
||||
ResolveFailed,
|
||||
ValidationFailure,
|
||||
@@ -50,8 +49,6 @@ class CertexpiryConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self._errors[CONF_HOST] = "connection_timeout"
|
||||
except ConnectionRefused:
|
||||
self._errors[CONF_HOST] = "connection_refused"
|
||||
except ConnectionReset:
|
||||
self._errors[CONF_HOST] = "connection_reset"
|
||||
except ValidationFailure:
|
||||
return True
|
||||
else:
|
||||
|
||||
@@ -25,7 +25,3 @@ class ConnectionTimeout(TemporaryFailure):
|
||||
|
||||
class ConnectionRefused(TemporaryFailure):
|
||||
"""Network connection refused."""
|
||||
|
||||
|
||||
class ConnectionReset(TemporaryFailure):
|
||||
"""Network connection reset."""
|
||||
|
||||
@@ -13,7 +13,6 @@ from homeassistant.util.ssl import get_default_context
|
||||
from .const import TIMEOUT
|
||||
from .errors import (
|
||||
ConnectionRefused,
|
||||
ConnectionReset,
|
||||
ConnectionTimeout,
|
||||
ResolveFailed,
|
||||
ValidationFailure,
|
||||
@@ -59,8 +58,6 @@ async def get_cert_expiry_timestamp(
|
||||
raise ConnectionRefused(
|
||||
f"Connection refused by server: {hostname}:{port}"
|
||||
) from err
|
||||
except ConnectionResetError as err:
|
||||
raise ConnectionReset(f"Connection reset by server: {hostname}:{port}") from err
|
||||
except ssl.CertificateError as err:
|
||||
raise ValidationFailure(err.verify_message) from err
|
||||
except ssl.SSLError as err:
|
||||
|
||||
@@ -14,8 +14,7 @@
|
||||
"error": {
|
||||
"resolve_failed": "This host cannot be resolved",
|
||||
"connection_timeout": "Timeout when connecting to this host",
|
||||
"connection_refused": "Connection refused when connecting to host",
|
||||
"connection_reset": "Connection reset when connecting to host"
|
||||
"connection_refused": "Connection refused when connecting to host"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
|
||||
@@ -34,7 +34,7 @@ from .const import (
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [Platform.CLIMATE, Platform.LIGHT, Platform.MEDIA_PLAYER]
|
||||
PLATFORMS = [Platform.LIGHT, Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -61,22 +61,13 @@ async def call_c4_api_retry(func, *func_args):
|
||||
try:
|
||||
return await func(*func_args)
|
||||
except client_exceptions.ClientError as exception:
|
||||
_LOGGER.debug(
|
||||
"Attempt %d/%d failed connecting to Control4 account API: %s",
|
||||
_LOGGER.error(
|
||||
"Try: %d, Error connecting to Control4 account API: %s",
|
||||
i + 1,
|
||||
API_RETRY_TIMES,
|
||||
exception,
|
||||
)
|
||||
exc = exception
|
||||
|
||||
_LOGGER.error(
|
||||
"Failed to connect to Control4 account API after %d attempts: %s",
|
||||
API_RETRY_TIMES,
|
||||
exc,
|
||||
)
|
||||
raise ConfigEntryNotReady(
|
||||
f"Failed to connect to Control4 account API after {API_RETRY_TIMES} attempts"
|
||||
) from exc
|
||||
raise ConfigEntryNotReady(exc) from exc
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) -> bool:
|
||||
@@ -89,9 +80,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) ->
|
||||
await account.getAccountBearerToken()
|
||||
except client_exceptions.ClientError as exception:
|
||||
_LOGGER.error("Error connecting to Control4 account API: %s", exception)
|
||||
raise ConfigEntryNotReady(
|
||||
"Error connecting to Control4 account API to get bearer token"
|
||||
) from exception
|
||||
raise ConfigEntryNotReady from exception
|
||||
except BadCredentials as exception:
|
||||
_LOGGER.error(
|
||||
(
|
||||
@@ -133,34 +122,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) ->
|
||||
)
|
||||
|
||||
# Store all items found on controller for platforms to use
|
||||
try:
|
||||
all_items_raw = await director.getAllItemInfo()
|
||||
except (TimeoutError, client_exceptions.ClientError) as err:
|
||||
_LOGGER.error(
|
||||
"Timeout connecting to Control4 controller at %s",
|
||||
config[CONF_HOST],
|
||||
)
|
||||
raise ConfigEntryNotReady(
|
||||
f"Timeout connecting to Control4 controller at {config[CONF_HOST]}"
|
||||
) from err
|
||||
|
||||
director_all_items: list[dict[str, Any]] = json.loads(all_items_raw)
|
||||
director_all_items: list[dict[str, Any]] = json.loads(
|
||||
await director.getAllItemInfo()
|
||||
)
|
||||
|
||||
# Check if OS version is 3 or higher to get UI configuration
|
||||
ui_configuration: dict[str, Any] | None = None
|
||||
if int(director_sw_version.split(".")[0]) >= 3:
|
||||
try:
|
||||
ui_config_raw = await director.getUiConfiguration()
|
||||
except (TimeoutError, client_exceptions.ClientError) as err:
|
||||
_LOGGER.error(
|
||||
"Timeout getting UI configuration from Control4 controller at %s",
|
||||
config[CONF_HOST],
|
||||
)
|
||||
raise ConfigEntryNotReady(
|
||||
f"Timeout getting UI configuration from Control4 controller at {config[CONF_HOST]}"
|
||||
) from err
|
||||
|
||||
ui_configuration = json.loads(ui_config_raw)
|
||||
ui_configuration = json.loads(await director.getUiConfiguration())
|
||||
|
||||
# Load options from config entry
|
||||
scan_interval: int = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
"""Platform for Control4 Climate/Thermostat."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyControl4.climate import C4Climate
|
||||
from pyControl4.error_handling import C4Exception
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ATTR_TARGET_TEMP_HIGH,
|
||||
ATTR_TARGET_TEMP_LOW,
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACAction,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from . import Control4ConfigEntry, Control4RuntimeData, get_items_of_category
|
||||
from .const import CONTROL4_ENTITY_TYPE
|
||||
from .director_utils import update_variables_for_config_entry
|
||||
from .entity import Control4Entity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONTROL4_CATEGORY = "comfort"
|
||||
|
||||
# Control4 variable names
|
||||
CONTROL4_HVAC_STATE = "HVAC_STATE"
|
||||
CONTROL4_HVAC_MODE = "HVAC_MODE"
|
||||
CONTROL4_CURRENT_TEMPERATURE = "TEMPERATURE_F"
|
||||
CONTROL4_HUMIDITY = "HUMIDITY"
|
||||
CONTROL4_COOL_SETPOINT = "COOL_SETPOINT_F"
|
||||
CONTROL4_HEAT_SETPOINT = "HEAT_SETPOINT_F"
|
||||
|
||||
VARIABLES_OF_INTEREST = {
|
||||
CONTROL4_HVAC_STATE,
|
||||
CONTROL4_HVAC_MODE,
|
||||
CONTROL4_CURRENT_TEMPERATURE,
|
||||
CONTROL4_HUMIDITY,
|
||||
CONTROL4_COOL_SETPOINT,
|
||||
CONTROL4_HEAT_SETPOINT,
|
||||
}
|
||||
|
||||
# Map Control4 HVAC modes to Home Assistant
|
||||
C4_TO_HA_HVAC_MODE = {
|
||||
"Off": HVACMode.OFF,
|
||||
"Cool": HVACMode.COOL,
|
||||
"Heat": HVACMode.HEAT,
|
||||
"Auto": HVACMode.HEAT_COOL,
|
||||
}
|
||||
|
||||
HA_TO_C4_HVAC_MODE = {v: k for k, v in C4_TO_HA_HVAC_MODE.items()}
|
||||
|
||||
# Map Control4 HVAC state to Home Assistant HVAC action
|
||||
C4_TO_HA_HVAC_ACTION = {
|
||||
"heating": HVACAction.HEATING,
|
||||
"cooling": HVACAction.COOLING,
|
||||
"idle": HVACAction.IDLE,
|
||||
"off": HVACAction.OFF,
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: Control4ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Control4 thermostats from a config entry."""
|
||||
runtime_data = entry.runtime_data
|
||||
|
||||
async def async_update_data() -> dict[int, dict[str, Any]]:
|
||||
"""Fetch data from Control4 director for thermostats."""
|
||||
try:
|
||||
return await update_variables_for_config_entry(
|
||||
hass, entry, VARIABLES_OF_INTEREST
|
||||
)
|
||||
except C4Exception as err:
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
|
||||
coordinator = DataUpdateCoordinator[dict[int, dict[str, Any]]](
|
||||
hass,
|
||||
_LOGGER,
|
||||
name="climate",
|
||||
update_method=async_update_data,
|
||||
update_interval=timedelta(seconds=runtime_data.scan_interval),
|
||||
config_entry=entry,
|
||||
)
|
||||
|
||||
# Fetch initial data so we have data when entities subscribe
|
||||
await coordinator.async_refresh()
|
||||
|
||||
items_of_category = await get_items_of_category(hass, entry, CONTROL4_CATEGORY)
|
||||
entity_list = []
|
||||
for item in items_of_category:
|
||||
try:
|
||||
if item["type"] == CONTROL4_ENTITY_TYPE:
|
||||
item_name = item["name"]
|
||||
item_id = item["id"]
|
||||
item_parent_id = item["parentId"]
|
||||
item_manufacturer = None
|
||||
item_device_name = None
|
||||
item_model = None
|
||||
|
||||
for parent_item in items_of_category:
|
||||
if parent_item["id"] == item_parent_id:
|
||||
item_manufacturer = parent_item.get("manufacturer")
|
||||
item_device_name = parent_item.get("roomName")
|
||||
item_model = parent_item.get("model")
|
||||
else:
|
||||
continue
|
||||
except KeyError:
|
||||
_LOGGER.exception(
|
||||
"Unknown device properties received from Control4: %s",
|
||||
item,
|
||||
)
|
||||
continue
|
||||
|
||||
# Skip if we don't have data for this thermostat
|
||||
if item_id not in coordinator.data:
|
||||
_LOGGER.warning(
|
||||
"Couldn't get climate state data for %s (ID: %s), skipping setup",
|
||||
item_name,
|
||||
item_id,
|
||||
)
|
||||
continue
|
||||
|
||||
entity_list.append(
|
||||
Control4Climate(
|
||||
runtime_data,
|
||||
coordinator,
|
||||
item_name,
|
||||
item_id,
|
||||
item_device_name,
|
||||
item_manufacturer,
|
||||
item_model,
|
||||
item_parent_id,
|
||||
)
|
||||
)
|
||||
|
||||
async_add_entities(entity_list)
|
||||
|
||||
|
||||
class Control4Climate(Control4Entity, ClimateEntity):
|
||||
"""Control4 climate entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_temperature_unit = UnitOfTemperature.FAHRENHEIT
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
)
|
||||
_attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
runtime_data: Control4RuntimeData,
|
||||
coordinator: DataUpdateCoordinator[dict[int, dict[str, Any]]],
|
||||
name: str,
|
||||
idx: int,
|
||||
device_name: str | None,
|
||||
device_manufacturer: str | None,
|
||||
device_model: str | None,
|
||||
device_id: int,
|
||||
) -> None:
|
||||
"""Initialize Control4 climate entity."""
|
||||
super().__init__(
|
||||
runtime_data,
|
||||
coordinator,
|
||||
name,
|
||||
idx,
|
||||
device_name,
|
||||
device_manufacturer,
|
||||
device_model,
|
||||
device_id,
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return super().available and self._thermostat_data is not None
|
||||
|
||||
def _create_api_object(self) -> C4Climate:
|
||||
"""Create a pyControl4 device object.
|
||||
|
||||
This exists so the director token used is always the latest one, without needing to re-init the entire entity.
|
||||
"""
|
||||
return C4Climate(self.runtime_data.director, self._idx)
|
||||
|
||||
@property
|
||||
def _thermostat_data(self) -> dict[str, Any] | None:
|
||||
"""Return the thermostat data from the coordinator."""
|
||||
return self.coordinator.data.get(self._idx)
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
data = self._thermostat_data
|
||||
if data is None:
|
||||
return None
|
||||
return data.get(CONTROL4_CURRENT_TEMPERATURE)
|
||||
|
||||
@property
|
||||
def current_humidity(self) -> int | None:
|
||||
"""Return the current humidity."""
|
||||
data = self._thermostat_data
|
||||
if data is None:
|
||||
return None
|
||||
humidity = data.get(CONTROL4_HUMIDITY)
|
||||
return int(humidity) if humidity is not None else None
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
"""Return current HVAC mode."""
|
||||
data = self._thermostat_data
|
||||
if data is None:
|
||||
return HVACMode.OFF
|
||||
c4_mode = data.get(CONTROL4_HVAC_MODE) or ""
|
||||
return C4_TO_HA_HVAC_MODE.get(c4_mode, HVACMode.OFF)
|
||||
|
||||
@property
|
||||
def hvac_action(self) -> HVACAction | None:
|
||||
"""Return current HVAC action."""
|
||||
data = self._thermostat_data
|
||||
if data is None:
|
||||
return None
|
||||
c4_state = data.get(CONTROL4_HVAC_STATE)
|
||||
if c4_state is None:
|
||||
return None
|
||||
# Convert state to lowercase for mapping
|
||||
return C4_TO_HA_HVAC_ACTION.get(str(c4_state).lower())
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the target temperature."""
|
||||
data = self._thermostat_data
|
||||
if data is None:
|
||||
return None
|
||||
hvac_mode = self.hvac_mode
|
||||
if hvac_mode == HVACMode.COOL:
|
||||
return data.get(CONTROL4_COOL_SETPOINT)
|
||||
if hvac_mode == HVACMode.HEAT:
|
||||
return data.get(CONTROL4_HEAT_SETPOINT)
|
||||
return None
|
||||
|
||||
@property
|
||||
def target_temperature_high(self) -> float | None:
|
||||
"""Return the high target temperature for auto mode."""
|
||||
data = self._thermostat_data
|
||||
if data is None:
|
||||
return None
|
||||
if self.hvac_mode == HVACMode.HEAT_COOL:
|
||||
return data.get(CONTROL4_COOL_SETPOINT)
|
||||
return None
|
||||
|
||||
@property
|
||||
def target_temperature_low(self) -> float | None:
|
||||
"""Return the low target temperature for auto mode."""
|
||||
data = self._thermostat_data
|
||||
if data is None:
|
||||
return None
|
||||
if self.hvac_mode == HVACMode.HEAT_COOL:
|
||||
return data.get(CONTROL4_HEAT_SETPOINT)
|
||||
return None
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new target HVAC mode."""
|
||||
c4_hvac_mode = HA_TO_C4_HVAC_MODE[hvac_mode]
|
||||
c4_climate = self._create_api_object()
|
||||
await c4_climate.setHvacMode(c4_hvac_mode)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature."""
|
||||
c4_climate = self._create_api_object()
|
||||
low_temp = kwargs.get(ATTR_TARGET_TEMP_LOW)
|
||||
high_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH)
|
||||
temp = kwargs.get(ATTR_TEMPERATURE)
|
||||
|
||||
# Handle temperature range for auto mode
|
||||
if self.hvac_mode == HVACMode.HEAT_COOL:
|
||||
if low_temp is not None:
|
||||
await c4_climate.setHeatSetpointF(low_temp)
|
||||
if high_temp is not None:
|
||||
await c4_climate.setCoolSetpointF(high_temp)
|
||||
# Handle single temperature setpoint
|
||||
elif temp is not None:
|
||||
if self.hvac_mode == HVACMode.COOL:
|
||||
await c4_climate.setCoolSetpointF(temp)
|
||||
elif self.hvac_mode == HVACMode.HEAT:
|
||||
await c4_climate.setHeatSetpointF(temp)
|
||||
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -7,7 +7,6 @@ from collections.abc import AsyncGenerator, AsyncIterable, Callable, Generator
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import asdict, dataclass, field, replace
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, TypedDict, cast
|
||||
@@ -17,18 +16,14 @@ import voluptuous as vol
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, TemplateError
|
||||
from homeassistant.helpers import chat_session, frame, intent, llm, template
|
||||
from homeassistant.util.dt import utcnow
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
from homeassistant.util.json import JsonObjectType
|
||||
|
||||
from . import trace
|
||||
from .const import ChatLogEventType
|
||||
from .models import ConversationInput, ConversationResult
|
||||
|
||||
DATA_CHAT_LOGS: HassKey[dict[str, ChatLog]] = HassKey("conversation_chat_logs")
|
||||
DATA_SUBSCRIPTIONS: HassKey[
|
||||
list[Callable[[str, ChatLogEventType, dict[str, Any]], None]]
|
||||
] = HassKey("conversation_chat_log_subscriptions")
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
current_chat_log: ContextVar[ChatLog | None] = ContextVar(
|
||||
@@ -36,40 +31,6 @@ current_chat_log: ContextVar[ChatLog | None] = ContextVar(
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_subscribe_chat_logs(
|
||||
hass: HomeAssistant,
|
||||
callback_func: Callable[[str, ChatLogEventType, dict[str, Any]], None],
|
||||
) -> Callable[[], None]:
|
||||
"""Subscribe to all chat logs."""
|
||||
subscriptions = hass.data.get(DATA_SUBSCRIPTIONS)
|
||||
if subscriptions is None:
|
||||
subscriptions = []
|
||||
hass.data[DATA_SUBSCRIPTIONS] = subscriptions
|
||||
|
||||
subscriptions.append(callback_func)
|
||||
|
||||
@callback
|
||||
def unsubscribe() -> None:
|
||||
"""Unsubscribe from chat logs."""
|
||||
subscriptions.remove(callback_func)
|
||||
|
||||
return unsubscribe
|
||||
|
||||
|
||||
@callback
|
||||
def _async_notify_subscribers(
|
||||
hass: HomeAssistant,
|
||||
conversation_id: str,
|
||||
event_type: ChatLogEventType,
|
||||
data: dict[str, Any],
|
||||
) -> None:
|
||||
"""Notify subscribers of a chat log event."""
|
||||
if subscriptions := hass.data.get(DATA_SUBSCRIPTIONS):
|
||||
for callback_func in subscriptions:
|
||||
callback_func(conversation_id, event_type, data)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def async_get_chat_log(
|
||||
hass: HomeAssistant,
|
||||
@@ -102,8 +63,6 @@ def async_get_chat_log(
|
||||
all_chat_logs = {}
|
||||
hass.data[DATA_CHAT_LOGS] = all_chat_logs
|
||||
|
||||
is_new_log = session.conversation_id not in all_chat_logs
|
||||
|
||||
if chat_log := all_chat_logs.get(session.conversation_id):
|
||||
chat_log = replace(chat_log, content=chat_log.content.copy())
|
||||
else:
|
||||
@@ -112,15 +71,6 @@ def async_get_chat_log(
|
||||
if chat_log_delta_listener:
|
||||
chat_log.delta_listener = chat_log_delta_listener
|
||||
|
||||
# Fire CREATED event for new chat logs before any content is added
|
||||
if is_new_log:
|
||||
_async_notify_subscribers(
|
||||
hass,
|
||||
session.conversation_id,
|
||||
ChatLogEventType.CREATED,
|
||||
{"chat_log": chat_log.as_dict()},
|
||||
)
|
||||
|
||||
if user_input is not None:
|
||||
chat_log.async_add_user_content(UserContent(content=user_input.text))
|
||||
|
||||
@@ -134,28 +84,14 @@ def async_get_chat_log(
|
||||
LOGGER.debug(
|
||||
"Chat Log opened but no assistant message was added, ignoring update"
|
||||
)
|
||||
# If this was a new log but nothing was added, fire DELETED to clean up
|
||||
if is_new_log:
|
||||
_async_notify_subscribers(
|
||||
hass,
|
||||
session.conversation_id,
|
||||
ChatLogEventType.DELETED,
|
||||
{},
|
||||
)
|
||||
return
|
||||
|
||||
if is_new_log:
|
||||
if session.conversation_id not in all_chat_logs:
|
||||
|
||||
@callback
|
||||
def do_cleanup() -> None:
|
||||
"""Handle cleanup."""
|
||||
all_chat_logs.pop(session.conversation_id)
|
||||
_async_notify_subscribers(
|
||||
hass,
|
||||
session.conversation_id,
|
||||
ChatLogEventType.DELETED,
|
||||
{},
|
||||
)
|
||||
|
||||
session.async_on_cleanup(do_cleanup)
|
||||
|
||||
@@ -164,16 +100,6 @@ def async_get_chat_log(
|
||||
|
||||
all_chat_logs[session.conversation_id] = chat_log
|
||||
|
||||
# For new logs, CREATED was already fired before content was added
|
||||
# For existing logs, fire UPDATED
|
||||
if not is_new_log:
|
||||
_async_notify_subscribers(
|
||||
hass,
|
||||
session.conversation_id,
|
||||
ChatLogEventType.UPDATED,
|
||||
{"chat_log": chat_log.as_dict()},
|
||||
)
|
||||
|
||||
|
||||
class ConverseError(HomeAssistantError):
|
||||
"""Error during initialization of conversation.
|
||||
@@ -203,11 +129,6 @@ class SystemContent:
|
||||
|
||||
role: Literal["system"] = field(init=False, default="system")
|
||||
content: str
|
||||
created: datetime = field(init=False, default_factory=utcnow)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the content."""
|
||||
return {"role": self.role, "content": self.content}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -217,20 +138,6 @@ class UserContent:
|
||||
role: Literal["user"] = field(init=False, default="user")
|
||||
content: str
|
||||
attachments: list[Attachment] | None = field(default=None)
|
||||
created: datetime = field(init=False, default_factory=utcnow)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the content."""
|
||||
result: dict[str, Any] = {
|
||||
"role": self.role,
|
||||
"content": self.content,
|
||||
"created": self.created,
|
||||
}
|
||||
if self.attachments:
|
||||
result["attachments"] = [
|
||||
attachment.as_dict() for attachment in self.attachments
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -246,14 +153,6 @@ class Attachment:
|
||||
path: Path
|
||||
"""Path to the attachment on disk."""
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the attachment."""
|
||||
return {
|
||||
"media_content_id": self.media_content_id,
|
||||
"mime_type": self.mime_type,
|
||||
"path": str(self.path),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssistantContent:
|
||||
@@ -265,22 +164,6 @@ class AssistantContent:
|
||||
thinking_content: str | None = None
|
||||
tool_calls: list[llm.ToolInput] | None = None
|
||||
native: Any = None
|
||||
created: datetime = field(init=False, default_factory=utcnow)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the content."""
|
||||
result: dict[str, Any] = {
|
||||
"role": self.role,
|
||||
"agent_id": self.agent_id,
|
||||
"created": self.created,
|
||||
}
|
||||
if self.content:
|
||||
result["content"] = self.content
|
||||
if self.thinking_content:
|
||||
result["thinking_content"] = self.thinking_content
|
||||
if self.tool_calls:
|
||||
result["tool_calls"] = self.tool_calls
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -292,18 +175,6 @@ class ToolResultContent:
|
||||
tool_call_id: str
|
||||
tool_name: str
|
||||
tool_result: JsonObjectType
|
||||
created: datetime = field(init=False, default_factory=utcnow)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the content."""
|
||||
return {
|
||||
"role": self.role,
|
||||
"agent_id": self.agent_id,
|
||||
"tool_call_id": self.tool_call_id,
|
||||
"tool_name": self.tool_name,
|
||||
"tool_result": self.tool_result,
|
||||
"created": self.created,
|
||||
}
|
||||
|
||||
|
||||
type Content = SystemContent | UserContent | AssistantContent | ToolResultContent
|
||||
@@ -339,16 +210,6 @@ class ChatLog:
|
||||
llm_api: llm.APIInstance | None = None
|
||||
delta_listener: Callable[[ChatLog, dict], None] | None = None
|
||||
llm_input_provided_index = 0
|
||||
created: datetime = field(init=False, default_factory=utcnow)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the chat log."""
|
||||
return {
|
||||
"conversation_id": self.conversation_id,
|
||||
"continue_conversation": self.continue_conversation,
|
||||
"content": [c.as_dict() for c in self.content],
|
||||
"created": self.created,
|
||||
}
|
||||
|
||||
@property
|
||||
def continue_conversation(self) -> bool:
|
||||
@@ -380,12 +241,6 @@ class ChatLog:
|
||||
"""Add user content to the log."""
|
||||
LOGGER.debug("Adding user content: %s", content)
|
||||
self.content.append(content)
|
||||
_async_notify_subscribers(
|
||||
self.hass,
|
||||
self.conversation_id,
|
||||
ChatLogEventType.CONTENT_ADDED,
|
||||
{"content": content.as_dict()},
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_add_assistant_content_without_tools(
|
||||
@@ -404,12 +259,6 @@ class ChatLog:
|
||||
):
|
||||
raise ValueError("Non-external tool calls not allowed")
|
||||
self.content.append(content)
|
||||
_async_notify_subscribers(
|
||||
self.hass,
|
||||
self.conversation_id,
|
||||
ChatLogEventType.CONTENT_ADDED,
|
||||
{"content": content.as_dict()},
|
||||
)
|
||||
|
||||
async def async_add_assistant_content(
|
||||
self,
|
||||
@@ -468,14 +317,6 @@ class ChatLog:
|
||||
tool_result=tool_result,
|
||||
)
|
||||
self.content.append(response_content)
|
||||
_async_notify_subscribers(
|
||||
self.hass,
|
||||
self.conversation_id,
|
||||
ChatLogEventType.CONTENT_ADDED,
|
||||
{
|
||||
"content": response_content.as_dict(),
|
||||
},
|
||||
)
|
||||
yield response_content
|
||||
|
||||
async def async_add_delta_content_stream(
|
||||
@@ -752,12 +593,6 @@ class ChatLog:
|
||||
self.llm_api = llm_api
|
||||
self.extra_system_prompt = extra_system_prompt
|
||||
self.content[0] = SystemContent(content=prompt)
|
||||
_async_notify_subscribers(
|
||||
self.hass,
|
||||
self.conversation_id,
|
||||
ChatLogEventType.UPDATED,
|
||||
{"chat_log": self.as_dict()},
|
||||
)
|
||||
|
||||
LOGGER.debug("Prompt: %s", self.content)
|
||||
LOGGER.debug("Tools: %s", self.llm_api.tools if self.llm_api else None)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import IntFlag, StrEnum
|
||||
from enum import IntFlag
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
@@ -30,13 +30,3 @@ class ConversationEntityFeature(IntFlag):
|
||||
"""Supported features of the conversation entity."""
|
||||
|
||||
CONTROL = 1
|
||||
|
||||
|
||||
class ChatLogEventType(StrEnum):
|
||||
"""Chat log event type."""
|
||||
|
||||
INITIAL_STATE = "initial_state"
|
||||
CREATED = "created"
|
||||
UPDATED = "updated"
|
||||
DELETED = "deleted"
|
||||
CONTENT_ADDED = "content_added"
|
||||
|
||||
@@ -12,7 +12,6 @@ from homeassistant.components import http, websocket_api
|
||||
from homeassistant.components.http.data_validator import RequestDataValidator
|
||||
from homeassistant.const import MATCH_ALL
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.chat_session import async_get_chat_session
|
||||
from homeassistant.util import language as language_util
|
||||
|
||||
from .agent_manager import (
|
||||
@@ -21,8 +20,7 @@ from .agent_manager import (
|
||||
async_get_agent,
|
||||
get_agent_manager,
|
||||
)
|
||||
from .chat_log import DATA_CHAT_LOGS, async_get_chat_log, async_subscribe_chat_logs
|
||||
from .const import DATA_COMPONENT, ChatLogEventType
|
||||
from .const import DATA_COMPONENT
|
||||
from .entity import ConversationEntity
|
||||
from .models import ConversationInput
|
||||
|
||||
@@ -37,8 +35,6 @@ def async_setup(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, websocket_list_sentences)
|
||||
websocket_api.async_register_command(hass, websocket_hass_agent_debug)
|
||||
websocket_api.async_register_command(hass, websocket_hass_agent_language_scores)
|
||||
websocket_api.async_register_command(hass, websocket_subscribe_chat_log)
|
||||
websocket_api.async_register_command(hass, websocket_subscribe_chat_log_index)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
@@ -269,114 +265,3 @@ class ConversationProcessView(http.HomeAssistantView):
|
||||
)
|
||||
|
||||
return self.json(result.as_dict())
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "conversation/chat_log/subscribe",
|
||||
vol.Required("conversation_id"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
def websocket_subscribe_chat_log(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Subscribe to a chat log."""
|
||||
msg_id = msg["id"]
|
||||
subscribed_conversation = msg["conversation_id"]
|
||||
|
||||
chat_logs = hass.data.get(DATA_CHAT_LOGS)
|
||||
|
||||
if not chat_logs or subscribed_conversation not in chat_logs:
|
||||
connection.send_error(
|
||||
msg_id,
|
||||
websocket_api.ERR_NOT_FOUND,
|
||||
"Conversation chat log not found",
|
||||
)
|
||||
return
|
||||
|
||||
@callback
|
||||
def forward_events(conversation_id: str, event_type: str, data: dict) -> None:
|
||||
"""Forward chat log events to websocket connection."""
|
||||
if conversation_id != subscribed_conversation:
|
||||
return
|
||||
|
||||
connection.send_event(
|
||||
msg_id,
|
||||
{
|
||||
"conversation_id": conversation_id,
|
||||
"event_type": event_type,
|
||||
"data": data,
|
||||
},
|
||||
)
|
||||
|
||||
if event_type == ChatLogEventType.DELETED:
|
||||
unsubscribe()
|
||||
del connection.subscriptions[msg["id"]]
|
||||
|
||||
unsubscribe = async_subscribe_chat_logs(hass, forward_events)
|
||||
connection.subscriptions[msg["id"]] = unsubscribe
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
with (
|
||||
async_get_chat_session(hass, subscribed_conversation) as session,
|
||||
async_get_chat_log(hass, session) as chat_log,
|
||||
):
|
||||
connection.send_event(
|
||||
msg_id,
|
||||
{
|
||||
"event_type": ChatLogEventType.INITIAL_STATE,
|
||||
"data": chat_log.as_dict(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "conversation/chat_log/subscribe_index",
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
def websocket_subscribe_chat_log_index(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Subscribe to a chat log."""
|
||||
msg_id = msg["id"]
|
||||
|
||||
@callback
|
||||
def forward_events(
|
||||
conversation_id: str, event_type: ChatLogEventType, data: dict
|
||||
) -> None:
|
||||
"""Forward chat log events to websocket connection."""
|
||||
if event_type not in (ChatLogEventType.CREATED, ChatLogEventType.DELETED):
|
||||
return
|
||||
|
||||
connection.send_event(
|
||||
msg_id,
|
||||
{
|
||||
"conversation_id": conversation_id,
|
||||
"event_type": event_type,
|
||||
"data": data,
|
||||
},
|
||||
)
|
||||
|
||||
unsubscribe = async_subscribe_chat_logs(hass, forward_events)
|
||||
connection.subscriptions[msg["id"]] = unsubscribe
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
chat_logs = hass.data.get(DATA_CHAT_LOGS)
|
||||
|
||||
if not chat_logs:
|
||||
return
|
||||
|
||||
connection.send_event(
|
||||
msg_id,
|
||||
{
|
||||
"event_type": ChatLogEventType.INITIAL_STATE,
|
||||
"data": [c.as_dict() for c in chat_logs.values()],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pycync==0.4.2"]
|
||||
"requirements": ["pycync==0.4.1"]
|
||||
}
|
||||
|
||||
@@ -184,8 +184,7 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
CONF_HOST: self.host,
|
||||
CONF_PORT: self.port,
|
||||
CONF_API_KEY: self.api_key,
|
||||
},
|
||||
reload_on_update=False,
|
||||
}
|
||||
)
|
||||
|
||||
except TimeoutError:
|
||||
@@ -232,8 +231,7 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
updates={
|
||||
CONF_HOST: self.host,
|
||||
CONF_PORT: self.port,
|
||||
},
|
||||
reload_on_update=False,
|
||||
}
|
||||
)
|
||||
|
||||
self.context.update(
|
||||
@@ -267,8 +265,7 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
CONF_HOST: self.host,
|
||||
CONF_PORT: self.port,
|
||||
CONF_API_KEY: self.api_key,
|
||||
},
|
||||
reload_on_update=False,
|
||||
}
|
||||
)
|
||||
|
||||
self.context["configuration_url"] = HASSIO_CONFIGURATION_URL
|
||||
|
||||
@@ -80,7 +80,8 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
class DevoloScannerEntity(
|
||||
# The pylint disable is needed because of https://github.com/pylint-dev/pylint/issues/9138
|
||||
class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module
|
||||
CoordinatorEntity[DevoloDataUpdateCoordinator[dict[str, ConnectedStationInfo]]],
|
||||
ScannerEntity,
|
||||
):
|
||||
|
||||
@@ -122,12 +122,10 @@ class WanIpSensor(SensorEntity):
|
||||
try:
|
||||
async with asyncio.timeout(10):
|
||||
response = await self.resolver.query(self.hostname, self.querytype)
|
||||
except TimeoutError as err:
|
||||
_LOGGER.debug("Timeout while resolving host: %s", err)
|
||||
except TimeoutError:
|
||||
await self.resolver.close()
|
||||
except DNSError as err:
|
||||
_LOGGER.warning("Exception while resolving host: %s", err)
|
||||
await self.resolver.close()
|
||||
|
||||
if response:
|
||||
sorted_ips = sort_ips(
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/droplet",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pydroplet==2.3.4"],
|
||||
"requirements": ["pydroplet==2.3.3"],
|
||||
"zeroconf": ["_droplet._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import EcovacsConfigEntry
|
||||
from .const import SUPPORTED_LIFESPANS, SUPPORTED_STATION_ACTIONS
|
||||
from .const import SUPPORTED_LIFESPANS
|
||||
from .entity import (
|
||||
EcovacsCapabilityEntityDescription,
|
||||
EcovacsDescriptionEntity,
|
||||
@@ -62,7 +62,7 @@ STATION_ENTITY_DESCRIPTIONS = tuple(
|
||||
key=f"station_action_{action.name.lower()}",
|
||||
translation_key=f"station_action_{action.name.lower()}",
|
||||
)
|
||||
for action in SUPPORTED_STATION_ACTIONS
|
||||
for action in StationAction
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -23,11 +23,7 @@ SUPPORTED_LIFESPANS = (
|
||||
LifeSpan.STATION_FILTER,
|
||||
)
|
||||
|
||||
SUPPORTED_STATION_ACTIONS = (
|
||||
StationAction.CLEAN_BASE,
|
||||
StationAction.DRY_MOP,
|
||||
StationAction.EMPTY_DUSTBIN,
|
||||
)
|
||||
SUPPORTED_STATION_ACTIONS = (StationAction.EMPTY_DUSTBIN,)
|
||||
|
||||
LEGACY_SUPPORTED_LIFESPANS = (
|
||||
"main_brush",
|
||||
|
||||
@@ -36,12 +36,6 @@
|
||||
"reset_lifespan_round_mop": {
|
||||
"default": "mdi:broom"
|
||||
},
|
||||
"station_action_clean_base": {
|
||||
"default": "mdi:home"
|
||||
},
|
||||
"station_action_dry_mop": {
|
||||
"default": "mdi:broom"
|
||||
},
|
||||
"station_action_empty_dustbin": {
|
||||
"default": "mdi:delete-restore"
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==16.1.0"]
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==15.1.0"]
|
||||
}
|
||||
|
||||
@@ -70,12 +70,6 @@
|
||||
"reset_lifespan_side_brush": {
|
||||
"name": "Reset side brush lifespan"
|
||||
},
|
||||
"station_action_clean_base": {
|
||||
"name": "Clean base"
|
||||
},
|
||||
"station_action_dry_mop": {
|
||||
"name": "Dry mop"
|
||||
},
|
||||
"station_action_empty_dustbin": {
|
||||
"name": "Empty dustbin"
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.issue_registry import async_delete_issue
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import assist_satellite, dashboard, ffmpeg_proxy
|
||||
from . import dashboard, ffmpeg_proxy
|
||||
from .const import CONF_BLUETOOTH_MAC_ADDRESS, CONF_NOISE_PSK, DOMAIN
|
||||
from .domain_data import DomainData
|
||||
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
|
||||
@@ -31,7 +31,6 @@ CLIENT_INFO = f"Home Assistant {ha_version}"
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the esphome component."""
|
||||
ffmpeg_proxy.async_setup(hass)
|
||||
await assist_satellite.async_setup(hass)
|
||||
await dashboard.async_setup(hass)
|
||||
return True
|
||||
|
||||
|
||||
@@ -5,12 +5,9 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterable
|
||||
from functools import partial
|
||||
import hashlib
|
||||
import io
|
||||
from itertools import chain
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import socket
|
||||
from typing import Any, cast
|
||||
import wave
|
||||
@@ -22,12 +19,9 @@ from aioesphomeapi import (
|
||||
VoiceAssistantAudioSettings,
|
||||
VoiceAssistantCommandFlag,
|
||||
VoiceAssistantEventType,
|
||||
VoiceAssistantExternalWakeWord,
|
||||
VoiceAssistantFeature,
|
||||
VoiceAssistantTimerEventType,
|
||||
)
|
||||
import voluptuous as vol
|
||||
from voluptuous.humanize import humanize_error
|
||||
|
||||
from homeassistant.components import assist_satellite, tts
|
||||
from homeassistant.components.assist_pipeline import (
|
||||
@@ -35,7 +29,6 @@ from homeassistant.components.assist_pipeline import (
|
||||
PipelineEventType,
|
||||
PipelineStage,
|
||||
)
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
from homeassistant.components.intent import (
|
||||
TimerEventType,
|
||||
TimerInfo,
|
||||
@@ -46,11 +39,8 @@ from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.network import get_url
|
||||
from homeassistant.helpers.singleton import singleton
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .const import DOMAIN, WAKE_WORDS_API_PATH, WAKE_WORDS_DIR_NAME
|
||||
from .const import DOMAIN
|
||||
from .entity import EsphomeAssistEntity, convert_api_error_ha_error
|
||||
from .entry_data import ESPHomeConfigEntry
|
||||
from .enum_mapper import EsphomeEnumMapper
|
||||
@@ -94,16 +84,6 @@ _TIMER_EVENT_TYPES: EsphomeEnumMapper[VoiceAssistantTimerEventType, TimerEventTy
|
||||
|
||||
_ANNOUNCEMENT_TIMEOUT_SEC = 5 * 60 # 5 minutes
|
||||
_CONFIG_TIMEOUT_SEC = 5
|
||||
_WAKE_WORD_CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("type"): str,
|
||||
vol.Required("wake_word"): str,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
_DATA_WAKE_WORDS: HassKey[dict[str, VoiceAssistantExternalWakeWord]] = HassKey(
|
||||
"wake_word_cache"
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -202,14 +182,9 @@ class EsphomeAssistSatellite(
|
||||
|
||||
async def _update_satellite_config(self) -> None:
|
||||
"""Get the latest satellite configuration from the device."""
|
||||
wake_words = await async_get_custom_wake_words(self.hass)
|
||||
if wake_words:
|
||||
_LOGGER.debug("Found custom wake words: %s", sorted(wake_words.keys()))
|
||||
|
||||
try:
|
||||
config = await self.cli.get_voice_assistant_configuration(
|
||||
_CONFIG_TIMEOUT_SEC,
|
||||
external_wake_words=list(wake_words.values()),
|
||||
_CONFIG_TIMEOUT_SEC
|
||||
)
|
||||
except TimeoutError:
|
||||
# Placeholder config will be used
|
||||
@@ -809,78 +784,3 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol):
|
||||
return
|
||||
|
||||
self.transport.sendto(data, self.remote_addr)
|
||||
|
||||
|
||||
async def async_get_custom_wake_words(
|
||||
hass: HomeAssistant,
|
||||
) -> dict[str, VoiceAssistantExternalWakeWord]:
|
||||
"""Get available custom wake words."""
|
||||
return await hass.async_add_executor_job(_get_custom_wake_words, hass)
|
||||
|
||||
|
||||
@singleton(_DATA_WAKE_WORDS)
|
||||
def _get_custom_wake_words(
|
||||
hass: HomeAssistant,
|
||||
) -> dict[str, VoiceAssistantExternalWakeWord]:
|
||||
"""Get available custom wake words (singleton)."""
|
||||
wake_words_dir = Path(hass.config.path(WAKE_WORDS_DIR_NAME))
|
||||
wake_words: dict[str, VoiceAssistantExternalWakeWord] = {}
|
||||
|
||||
# Look for config/model files
|
||||
for config_path in wake_words_dir.glob("*.json"):
|
||||
wake_word_id = config_path.stem
|
||||
model_path = config_path.with_suffix(".tflite")
|
||||
if not model_path.exists():
|
||||
# Missing model file
|
||||
continue
|
||||
|
||||
with open(config_path, encoding="utf-8") as config_file:
|
||||
config_dict = json.load(config_file)
|
||||
try:
|
||||
config = _WAKE_WORD_CONFIG_SCHEMA(config_dict)
|
||||
except vol.Invalid as err:
|
||||
# Invalid config
|
||||
_LOGGER.debug(
|
||||
"Invalid wake word config: path=%s, error=%s",
|
||||
config_path,
|
||||
humanize_error(config_dict, err),
|
||||
)
|
||||
continue
|
||||
|
||||
with open(model_path, "rb") as model_file:
|
||||
model_hash = hashlib.sha256(model_file.read()).hexdigest()
|
||||
|
||||
model_size = model_path.stat().st_size
|
||||
config_rel_path = config_path.relative_to(wake_words_dir)
|
||||
|
||||
# Only intended for the internal network
|
||||
base_url = get_url(hass, prefer_external=False, allow_cloud=False)
|
||||
|
||||
wake_words[wake_word_id] = VoiceAssistantExternalWakeWord.from_dict(
|
||||
{
|
||||
"id": wake_word_id,
|
||||
"wake_word": config["wake_word"],
|
||||
"trained_languages": config_dict.get("trained_languages", []),
|
||||
"model_type": config["type"],
|
||||
"model_size": model_size,
|
||||
"model_hash": model_hash,
|
||||
"url": f"{base_url}{WAKE_WORDS_API_PATH}/{config_rel_path}",
|
||||
}
|
||||
)
|
||||
|
||||
return wake_words
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant) -> None:
|
||||
"""Set up the satellite."""
|
||||
wake_words_dir = Path(hass.config.path(WAKE_WORDS_DIR_NAME))
|
||||
|
||||
# Satellites will pull model files over HTTP
|
||||
await hass.http.async_register_static_paths(
|
||||
[
|
||||
StaticPathConfig(
|
||||
url_path=WAKE_WORDS_API_PATH,
|
||||
path=str(wake_words_dir),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@@ -27,6 +27,3 @@ STABLE_BLE_URL_VERSION = f"{STABLE_BLE_VERSION.major}.{STABLE_BLE_VERSION.minor}
|
||||
DEFAULT_URL = f"https://esphome.io/changelog/{STABLE_BLE_URL_VERSION}.html"
|
||||
|
||||
NO_WAKE_WORD: Final[str] = "no_wake_word"
|
||||
|
||||
WAKE_WORDS_DIR_NAME = "custom_wake_words"
|
||||
WAKE_WORDS_API_PATH = "/api/esphome/wake_words"
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
"""The Fing integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
|
||||
from .coordinator import FingConfigEntry, FingDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [Platform.DEVICE_TRACKER]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, config_entry: FingConfigEntry) -> bool:
|
||||
"""Set up the Fing component."""
|
||||
|
||||
coordinator = FingDataUpdateCoordinator(hass, config_entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
if coordinator.data.network_id is None:
|
||||
_LOGGER.warning(
|
||||
"Skip setting up Fing integration; Received an empty NetworkId from the request - Check if the API version is the latest"
|
||||
)
|
||||
raise ConfigEntryError(
|
||||
"The Agent's API version is outdated. Please update the agent to the latest version."
|
||||
)
|
||||
|
||||
config_entry.runtime_data = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, config_entry: FingConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
|
||||
@@ -1,114 +0,0 @@
|
||||
"""Config flow file."""
|
||||
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fing_agent_api import FingAgent
|
||||
import httpx
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_API_KEY, CONF_IP_ADDRESS, CONF_PORT
|
||||
|
||||
from .const import DOMAIN, UPNP_AVAILABLE
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FingConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Fing config flow."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Set up user step."""
|
||||
errors: dict[str, str] = {}
|
||||
description_placeholders: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
devices_response = None
|
||||
agent_info_response = None
|
||||
|
||||
self._async_abort_entries_match(
|
||||
{CONF_IP_ADDRESS: user_input[CONF_IP_ADDRESS]}
|
||||
)
|
||||
|
||||
fing_api = FingAgent(
|
||||
ip=user_input[CONF_IP_ADDRESS],
|
||||
port=int(user_input[CONF_PORT]),
|
||||
key=user_input[CONF_API_KEY],
|
||||
)
|
||||
|
||||
try:
|
||||
devices_response = await fing_api.get_devices()
|
||||
|
||||
with suppress(httpx.ConnectError):
|
||||
# The suppression is needed because the get_agent_info method isn't available for desktop agents
|
||||
agent_info_response = await fing_api.get_agent_info()
|
||||
|
||||
except httpx.NetworkError as _:
|
||||
errors["base"] = "cannot_connect"
|
||||
except httpx.TimeoutException as _:
|
||||
errors["base"] = "timeout_connect"
|
||||
except httpx.HTTPStatusError as exception:
|
||||
description_placeholders["message"] = (
|
||||
f"{exception.response.status_code} - {exception.response.reason_phrase}"
|
||||
)
|
||||
if exception.response.status_code == 401:
|
||||
errors["base"] = "invalid_api_key"
|
||||
else:
|
||||
errors["base"] = "http_status_error"
|
||||
except httpx.InvalidURL as _:
|
||||
errors["base"] = "url_error"
|
||||
except (
|
||||
httpx.HTTPError,
|
||||
httpx.CookieConflict,
|
||||
httpx.StreamError,
|
||||
) as ex:
|
||||
_LOGGER.error("Unexpected exception: %s", ex)
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
if (
|
||||
devices_response.network_id is not None
|
||||
and len(devices_response.network_id) > 0
|
||||
):
|
||||
agent_name = user_input.get(CONF_IP_ADDRESS)
|
||||
upnp_available = False
|
||||
if agent_info_response is not None:
|
||||
upnp_available = True
|
||||
agent_name = agent_info_response.agent_id
|
||||
await self.async_set_unique_id(agent_info_response.agent_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
data = {
|
||||
CONF_IP_ADDRESS: user_input[CONF_IP_ADDRESS],
|
||||
CONF_PORT: user_input[CONF_PORT],
|
||||
CONF_API_KEY: user_input[CONF_API_KEY],
|
||||
UPNP_AVAILABLE: upnp_available,
|
||||
}
|
||||
|
||||
return self.async_create_entry(
|
||||
title=f"Fing Agent {agent_name}",
|
||||
data=data,
|
||||
)
|
||||
|
||||
return self.async_abort(reason="api_version_error")
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_IP_ADDRESS): str,
|
||||
vol.Required(CONF_PORT, default="49090"): str,
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
}
|
||||
),
|
||||
user_input,
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders=description_placeholders,
|
||||
)
|
||||
@@ -1,4 +0,0 @@
|
||||
"""Const for the Fing integration."""
|
||||
|
||||
DOMAIN = "fing"
|
||||
UPNP_AVAILABLE = "upnp_available"
|
||||
@@ -1,85 +0,0 @@
|
||||
"""DataUpdateCoordinator for Fing integration."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from fing_agent_api import FingAgent
|
||||
from fing_agent_api.models import AgentInfoResponse, Device
|
||||
import httpx
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_KEY, CONF_IP_ADDRESS, CONF_PORT
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, UPNP_AVAILABLE
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type FingConfigEntry = ConfigEntry[FingDataUpdateCoordinator]
|
||||
|
||||
|
||||
@dataclass
|
||||
class FingDataObject:
|
||||
"""Fing Data Object."""
|
||||
|
||||
network_id: str | None = None
|
||||
agent_info: AgentInfoResponse | None = None
|
||||
devices: dict[str, Device] = field(default_factory=dict)
|
||||
|
||||
|
||||
class FingDataUpdateCoordinator(DataUpdateCoordinator[FingDataObject]):
|
||||
"""Class to manage fetching data from Fing Agent."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config_entry: FingConfigEntry) -> None:
|
||||
"""Initialize global Fing updater."""
|
||||
self._fing = FingAgent(
|
||||
ip=config_entry.data[CONF_IP_ADDRESS],
|
||||
port=int(config_entry.data[CONF_PORT]),
|
||||
key=config_entry.data[CONF_API_KEY],
|
||||
)
|
||||
self._upnp_available = config_entry.data[UPNP_AVAILABLE]
|
||||
update_interval = timedelta(seconds=30)
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=update_interval,
|
||||
config_entry=config_entry,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> FingDataObject:
|
||||
"""Fetch data from Fing Agent."""
|
||||
device_response = None
|
||||
agent_info_response = None
|
||||
try:
|
||||
device_response = await self._fing.get_devices()
|
||||
|
||||
if self._upnp_available:
|
||||
agent_info_response = await self._fing.get_agent_info()
|
||||
|
||||
except httpx.NetworkError as err:
|
||||
raise UpdateFailed("Failed to connect") from err
|
||||
except httpx.TimeoutException as err:
|
||||
raise UpdateFailed("Timeout establishing connection") from err
|
||||
except httpx.HTTPStatusError as err:
|
||||
if err.response.status_code == 401:
|
||||
raise UpdateFailed("Invalid API key") from err
|
||||
raise UpdateFailed(
|
||||
f"Http request failed -> {err.response.status_code} - {err.response.reason_phrase}"
|
||||
) from err
|
||||
except httpx.InvalidURL as err:
|
||||
raise UpdateFailed("Invalid hostname or IP address") from err
|
||||
except (
|
||||
httpx.HTTPError,
|
||||
httpx.CookieConflict,
|
||||
httpx.StreamError,
|
||||
) as err:
|
||||
raise UpdateFailed("Unexpected error from HTTP request") from err
|
||||
else:
|
||||
return FingDataObject(
|
||||
device_response.network_id,
|
||||
agent_info_response,
|
||||
{device.mac: device for device in device_response.devices},
|
||||
)
|
||||
@@ -1,127 +0,0 @@
|
||||
"""Platform for Device tracker integration."""
|
||||
|
||||
from fing_agent_api.models import Device
|
||||
|
||||
from homeassistant.components.device_tracker import ScannerEntity
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import FingConfigEntry
|
||||
from .coordinator import FingDataUpdateCoordinator
|
||||
from .utils import get_icon_from_type
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: FingConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Add sensors for passed config_entry in HA."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entity_registry = er.async_get(hass)
|
||||
tracked_devices: set[str] = set()
|
||||
|
||||
@callback
|
||||
def add_entities() -> None:
|
||||
latest_devices = set(coordinator.data.devices.keys())
|
||||
|
||||
devices_to_remove = tracked_devices - set(latest_devices)
|
||||
devices_to_add = set(latest_devices) - tracked_devices
|
||||
|
||||
entities_to_remove = []
|
||||
for entity_entry in entity_registry.entities.values():
|
||||
if entity_entry.config_entry_id != config_entry.entry_id:
|
||||
continue
|
||||
try:
|
||||
_, mac = entity_entry.unique_id.rsplit("-", 1)
|
||||
if mac in devices_to_remove:
|
||||
entities_to_remove.append(entity_entry.entity_id)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
for entity_id in entities_to_remove:
|
||||
entity_registry.async_remove(entity_id)
|
||||
|
||||
entities_to_add = []
|
||||
for mac_addr in devices_to_add:
|
||||
device = coordinator.data.devices[mac_addr]
|
||||
entities_to_add.append(FingTrackedDevice(coordinator, device))
|
||||
|
||||
tracked_devices.clear()
|
||||
tracked_devices.update(latest_devices)
|
||||
async_add_entities(entities_to_add)
|
||||
|
||||
add_entities()
|
||||
config_entry.async_on_unload(coordinator.async_add_listener(add_entities))
|
||||
|
||||
|
||||
class FingTrackedDevice(CoordinatorEntity[FingDataUpdateCoordinator], ScannerEntity):
|
||||
"""Represent a tracked device."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: FingDataUpdateCoordinator, device: Device) -> None:
|
||||
"""Set up FingDevice entity."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._device = device
|
||||
agent_id = coordinator.data.network_id
|
||||
if coordinator.data.agent_info is not None:
|
||||
agent_id = coordinator.data.agent_info.agent_id
|
||||
|
||||
self._attr_mac_address = self._device.mac
|
||||
self._attr_unique_id = f"{agent_id}-{self._attr_mac_address}"
|
||||
self._attr_name = self._device.name
|
||||
self._attr_icon = get_icon_from_type(self._device.type)
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Return true if the device is connected to the network."""
|
||||
return self._device.active
|
||||
|
||||
@property
|
||||
def ip_address(self) -> str | None:
|
||||
"""Return the primary ip address of the device."""
|
||||
return self._device.ip[0] if self._device.ip else None
|
||||
|
||||
@property
|
||||
def entity_registry_enabled_default(self) -> bool:
|
||||
"""Enable entity by default."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str | None:
|
||||
"""Return the unique ID of the entity."""
|
||||
return self._attr_unique_id
|
||||
|
||||
def check_for_updates(self, new_device: Device) -> bool:
|
||||
"""Return true if the device has updates."""
|
||||
new_device_ip = new_device.ip[0] if new_device.ip else None
|
||||
current_device_ip = self._device.ip[0] if self._device.ip else None
|
||||
|
||||
return (
|
||||
current_device_ip != new_device_ip
|
||||
or self._device.active != new_device.active
|
||||
or self._device.type != new_device.type
|
||||
or self._attr_name != new_device.name
|
||||
or self._attr_icon != get_icon_from_type(new_device.type)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
updated_device_data = self.coordinator.data.devices.get(self._device.mac)
|
||||
if updated_device_data is not None and self.check_for_updates(
|
||||
updated_device_data
|
||||
):
|
||||
self._device = updated_device_data
|
||||
self._attr_name = updated_device_data.name
|
||||
self._attr_icon = get_icon_from_type(updated_device_data.type)
|
||||
er.async_get(self.hass).async_update_entity(
|
||||
entity_id=self.entity_id,
|
||||
original_name=self._attr_name,
|
||||
original_icon=self._attr_icon,
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"domain": "fing",
|
||||
"name": "Fing",
|
||||
"codeowners": ["@Lorenzo-Gasparini"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/fing",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["fing_agent_api==1.0.3"]
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: The integration has no actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow: done
|
||||
config-flow-test-coverage: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: There are no actions in Fing integration.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Fing integration entities do not use events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: The integration has no actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: The integration has no options flow.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery: todo
|
||||
discovery-update-info: todo
|
||||
docs-data-update: done
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: done
|
||||
entity-category: todo
|
||||
entity-device-class:
|
||||
status: exempt
|
||||
comment: The integration creates only device tracker entities
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: done
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Set up Fing agent",
|
||||
"data": {
|
||||
"ip_address": "[%key:common::config_flow::data::ip%]",
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
},
|
||||
"data_description": {
|
||||
"ip_address": "IP address of the Fing agent.",
|
||||
"port": "Port number of the Fing API.",
|
||||
"api_key": "API key used to authenticate with the Fing API."
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]",
|
||||
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"url_error": "[%key:common::config_flow::error::invalid_host%]",
|
||||
"http_status_error": "HTTP request failed: {message}"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"api_version_error": "Your agent is using an outdated API version. The required 'network_id' parameter is missing. Please update to the latest API version."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
"""Utils functions."""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class DeviceType(Enum):
|
||||
"""Device types enum."""
|
||||
|
||||
GENERIC = "mdi:lan-connect"
|
||||
MOBILE = PHONE = "mdi:cellphone"
|
||||
TABLET = IPOD = EREADER = "mdi:tablet"
|
||||
WATCH = WEARABLE = "mdi:watch"
|
||||
CAR = AUTOMOTIVE = "mdi:car-back"
|
||||
MEDIA_PLAYER = "mdi:volume-high"
|
||||
TELEVISION = "mdi:television"
|
||||
GAME_CONSOLE = "mdi:nintendo-game-boy"
|
||||
STREAMING_DONGLE = "mdi:cast"
|
||||
LOUDSPEAKER = SOUND_SYSTEM = STB = SATELLITE = MUSIC = "mdi:speaker"
|
||||
DISC_PLAYER = "mdi:disk-player"
|
||||
REMOTE_CONTROL = "mdi:remote-tv"
|
||||
RADIO = "mdi:radio"
|
||||
PHOTO_CAMERA = PHOTOS = "mdi:camera"
|
||||
MICROPHONE = VOICE_CONTROL = "mdi:microphone"
|
||||
PROJECTOR = "mdi:projector"
|
||||
COMPUTER = DESKTOP = "mdi:desktop-tower"
|
||||
LAPTOP = "mdi:laptop"
|
||||
PRINTER = "mdi:printer"
|
||||
SCANNER = "mdi:scanner"
|
||||
POS = "mdi:printer-pos"
|
||||
CLOCK = "mdi:clock"
|
||||
BARCODE = "mdi:barcode"
|
||||
SURVEILLANCE_CAMERA = BABY_MONITOR = PET_MONITOR = "mdi:cctv"
|
||||
POE_PLUG = HEALTH_MONITOR = SMART_HOME = SMART_METER = APPLIANCE = SLEEP = (
|
||||
"mdi:home-automation"
|
||||
)
|
||||
SMART_PLUG = "mdi:power-plug"
|
||||
LIGHT = "mdi:lightbulb"
|
||||
THERMOSTAT = HEATING = "mdi:home-thermometer"
|
||||
POWER_SYSTEM = ENERGY = "mdi:lightning-bolt"
|
||||
SOLAR_PANEL = "mdi:solar-power"
|
||||
WASHER = "mdi:washing-machine"
|
||||
FRIDGE = "mdi:fridge"
|
||||
CLEANER = "mdi:vacuum"
|
||||
GARAGE = "mdi:garage"
|
||||
SPRINKLER = "mdi:sprinkler"
|
||||
BELL = "mdi:doorbell"
|
||||
KEY_LOCK = "mdi:lock-smart"
|
||||
CONTROL_PANEL = SMART_CONTROLLER = "mdi:alarm-panel"
|
||||
SCALE = "mdi:scale-bathroom"
|
||||
TOY = "mdi:teddy-bear"
|
||||
ROBOT = "mdi:robot"
|
||||
WEATHER = "mdi:weather-cloudy"
|
||||
ALARM = "mdi:alarm-light"
|
||||
MOTION_DETECTOR = "mdi:motion-sensor"
|
||||
SMOKE = HUMIDITY = SENSOR = DOMOTZ_BOX = FINGBOX = "mdi:smoke-detector"
|
||||
ROUTER = MODEM = GATEWAY = FIREWALL = VPN = SMALL_CELL = "mdi:router-network"
|
||||
WIFI = WIFI_EXTENDER = "mdi:wifi"
|
||||
NAS_STORAGE = "mdi:nas"
|
||||
SWITCH = "mdi:switch"
|
||||
USB = "mdi:usb"
|
||||
CLOUD = "mdi:cloud"
|
||||
BATTERY = "mdi:battery"
|
||||
NETWORK_APPLIANCE = "mdi:network"
|
||||
VIRTUAL_MACHINE = MAIL_SERVER = FILE_SERVER = PROXY_SERVER = WEB_SERVER = (
|
||||
DOMAIN_SERVER
|
||||
) = COMMUNICATION = "mdi:monitor"
|
||||
SERVER = "mdi:server"
|
||||
TERMINAL = "mdi:console"
|
||||
DATABASE = "mdi:database"
|
||||
RASPBERRY = ARDUINO = "mdi:raspberry-pi"
|
||||
PROCESSOR = CIRCUIT_CARD = RFID = "mdi:chip"
|
||||
INDUSTRIAL = "mdi:factory"
|
||||
MEDICAL = "mdi:medical-bag"
|
||||
VOIP = CONFERENCING = "mdi:phone-voip"
|
||||
FITNESS = "mdi:dumbbell"
|
||||
POOL = "mdi:pool"
|
||||
SECURITY_SYSTEM = "mdi:security"
|
||||
|
||||
|
||||
def get_icon_from_type(type: str) -> str:
|
||||
"""Return the right icon based on the type."""
|
||||
try:
|
||||
return DeviceType[type].value
|
||||
except (ValueError, KeyError):
|
||||
return "mdi:lan-connect"
|
||||
@@ -106,7 +106,7 @@ class FireflyDataUpdateCoordinator(DataUpdateCoordinator[FireflyCoordinatorData]
|
||||
for category in categories
|
||||
]
|
||||
primary_currency = await self.firefly.get_currency_primary()
|
||||
budgets = await self.firefly.get_budgets(start=start_date, end=end_date)
|
||||
budgets = await self.firefly.get_budgets()
|
||||
bills = await self.firefly.get_bills()
|
||||
except FireflyAuthenticationError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pyfirefly.models import Account, Budget, Category
|
||||
from pyfirefly.models import Account, Category
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.const import CONF_URL
|
||||
@@ -83,29 +83,3 @@ class FireflyCategoryBaseEntity(FireflyBaseEntity):
|
||||
self._attr_unique_id = (
|
||||
f"{coordinator.config_entry.unique_id}_category_{category.id}_{key}"
|
||||
)
|
||||
|
||||
|
||||
class FireflyBudgetBaseEntity(FireflyBaseEntity):
|
||||
"""Base class for Firefly III budget entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: FireflyDataUpdateCoordinator,
|
||||
budget: Budget,
|
||||
key: str,
|
||||
) -> None:
|
||||
"""Initialize a Firefly budget entity."""
|
||||
super().__init__(coordinator)
|
||||
self._budget = budget
|
||||
self._attr_device_info = DeviceInfo(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
manufacturer=MANUFACTURER,
|
||||
name=budget.attributes.name,
|
||||
configuration_url=f"{URL(coordinator.config_entry.data[CONF_URL])}/budgets/show/{budget.id}",
|
||||
identifiers={
|
||||
(DOMAIN, f"{coordinator.config_entry.entry_id}_budget_{budget.id}")
|
||||
},
|
||||
)
|
||||
self._attr_unique_id = (
|
||||
f"{coordinator.config_entry.unique_id}_budget_{budget.id}_{key}"
|
||||
)
|
||||
|
||||
@@ -12,9 +12,6 @@
|
||||
},
|
||||
"category": {
|
||||
"default": "mdi:label"
|
||||
},
|
||||
"budget": {
|
||||
"default": "mdi:chart-pie"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/firefly_iii",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyfirefly==0.1.8"]
|
||||
"requirements": ["pyfirefly==0.1.6"]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pyfirefly.models import Account, Budget, Category
|
||||
from pyfirefly.models import Account, Category
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
@@ -15,11 +15,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import FireflyConfigEntry, FireflyDataUpdateCoordinator
|
||||
from .entity import (
|
||||
FireflyAccountBaseEntity,
|
||||
FireflyBudgetBaseEntity,
|
||||
FireflyCategoryBaseEntity,
|
||||
)
|
||||
from .entity import FireflyAccountBaseEntity, FireflyCategoryBaseEntity
|
||||
|
||||
ACCOUNT_ROLE_MAPPING = {
|
||||
"defaultAsset": "default_asset",
|
||||
@@ -39,7 +35,6 @@ ACCOUNT_BALANCE = "account_balance"
|
||||
ACCOUNT_ROLE = "account_role"
|
||||
ACCOUNT_TYPE = "account_type"
|
||||
CATEGORY = "category"
|
||||
BUDGET = "budget"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -65,13 +60,6 @@ async def async_setup_entry(
|
||||
]
|
||||
)
|
||||
|
||||
entities.extend(
|
||||
[
|
||||
FireflyBudgetSensor(coordinator, budget, BUDGET)
|
||||
for budget in coordinator.data.budgets
|
||||
]
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -188,30 +176,3 @@ class FireflyCategorySensor(FireflyCategoryBaseEntity, SensorEntity):
|
||||
if spent == 0 and earned == 0:
|
||||
return None
|
||||
return spent + earned
|
||||
|
||||
|
||||
class FireflyBudgetSensor(FireflyBudgetBaseEntity, SensorEntity):
|
||||
"""Budget sensor."""
|
||||
|
||||
_attr_translation_key = "budget"
|
||||
_attr_device_class = SensorDeviceClass.MONETARY
|
||||
_attr_state_class = SensorStateClass.TOTAL
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: FireflyDataUpdateCoordinator,
|
||||
budget: Budget,
|
||||
key: str,
|
||||
) -> None:
|
||||
"""Initialize the budget sensor."""
|
||||
super().__init__(coordinator, budget, key)
|
||||
self._budget = budget
|
||||
self._attr_native_unit_of_measurement = (
|
||||
coordinator.data.primary_currency.attributes.code
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return spent value for this budget in the period."""
|
||||
spent_items = self._budget.attributes.spent or []
|
||||
return sum(float(item.sum) for item in spent_items if item.sum is not None)
|
||||
|
||||
@@ -72,9 +72,6 @@
|
||||
},
|
||||
"category": {
|
||||
"name": "Earned/Spent"
|
||||
},
|
||||
"budget": {
|
||||
"name": "Budget"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,4 +6,4 @@ CONF_DEBUG_UI = "debug_ui"
|
||||
DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time."
|
||||
HA_MANAGED_API_PORT = 11984
|
||||
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
|
||||
RECOMMENDED_VERSION = "1.9.11"
|
||||
RECOMMENDED_VERSION = "1.9.9"
|
||||
|
||||
@@ -186,7 +186,6 @@ async def async_setup_entry(
|
||||
class InverterSensor(CoordinatorEntity[GoodweUpdateCoordinator], SensorEntity):
|
||||
"""Entity representing individual inverter sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
entity_description: GoodweSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
|
||||
@@ -15,13 +15,7 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.json import json_loads
|
||||
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
CONF_RECOMMENDED,
|
||||
LOGGER,
|
||||
RECOMMENDED_AI_TASK_MAX_TOKENS,
|
||||
RECOMMENDED_IMAGE_MODEL,
|
||||
)
|
||||
from .const import CONF_CHAT_MODEL, CONF_RECOMMENDED, LOGGER, RECOMMENDED_IMAGE_MODEL
|
||||
from .entity import (
|
||||
ERROR_GETTING_RESPONSE,
|
||||
GoogleGenerativeAILLMBaseEntity,
|
||||
@@ -79,9 +73,7 @@ class GoogleGenerativeAITaskEntity(
|
||||
chat_log: conversation.ChatLog,
|
||||
) -> ai_task.GenDataTaskResult:
|
||||
"""Handle a generate data task."""
|
||||
await self._async_handle_chat_log(
|
||||
chat_log, task.structure, default_max_tokens=RECOMMENDED_AI_TASK_MAX_TOKENS
|
||||
)
|
||||
await self._async_handle_chat_log(chat_log, task.structure)
|
||||
|
||||
if not isinstance(chat_log.content[-1], conversation.AssistantContent):
|
||||
LOGGER.error(
|
||||
|
||||
@@ -32,8 +32,6 @@ CONF_TOP_K = "top_k"
|
||||
RECOMMENDED_TOP_K = 64
|
||||
CONF_MAX_TOKENS = "max_tokens"
|
||||
RECOMMENDED_MAX_TOKENS = 3000
|
||||
# Input 5000, output 19400 = 0.05 USD
|
||||
RECOMMENDED_AI_TASK_MAX_TOKENS = 19400
|
||||
CONF_HARASSMENT_BLOCK_THRESHOLD = "harassment_block_threshold"
|
||||
CONF_HATE_BLOCK_THRESHOLD = "hate_block_threshold"
|
||||
CONF_SEXUAL_BLOCK_THRESHOLD = "sexual_block_threshold"
|
||||
|
||||
@@ -472,7 +472,6 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
|
||||
self,
|
||||
chat_log: conversation.ChatLog,
|
||||
structure: vol.Schema | None = None,
|
||||
default_max_tokens: int | None = None,
|
||||
) -> None:
|
||||
"""Generate an answer for the chat log."""
|
||||
options = self.subentry.data
|
||||
@@ -619,9 +618,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
|
||||
if not chat_log.unresponded_tool_results:
|
||||
break
|
||||
|
||||
def create_generate_content_config(
|
||||
self, default_max_tokens: int | None = None
|
||||
) -> GenerateContentConfig:
|
||||
def create_generate_content_config(self) -> GenerateContentConfig:
|
||||
"""Create the GenerateContentConfig for the LLM."""
|
||||
options = self.subentry.data
|
||||
model = options.get(CONF_CHAT_MODEL, self.default_model)
|
||||
@@ -635,12 +632,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
|
||||
temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE),
|
||||
top_k=options.get(CONF_TOP_K, RECOMMENDED_TOP_K),
|
||||
top_p=options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
|
||||
max_output_tokens=options.get(
|
||||
CONF_MAX_TOKENS,
|
||||
default_max_tokens
|
||||
if default_max_tokens is not None
|
||||
else RECOMMENDED_MAX_TOKENS,
|
||||
),
|
||||
max_output_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
|
||||
safety_settings=[
|
||||
SafetySetting(
|
||||
category=HarmCategory.HARM_CATEGORY_HATE_SPEECH,
|
||||
|
||||
@@ -59,7 +59,6 @@ class GoogleGenerativeAITextToSpeechEntity(
|
||||
"en-US",
|
||||
"es-US",
|
||||
"fr-FR",
|
||||
"he-IL",
|
||||
"hi-IN",
|
||||
"id-ID",
|
||||
"it-IT",
|
||||
|
||||
@@ -72,7 +72,6 @@ PLATFORMS = [
|
||||
Platform.NOTIFY,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.VALVE,
|
||||
]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -35,7 +35,6 @@ from .media_player import MediaPlayerGroup, async_create_preview_media_player
|
||||
from .notify import async_create_preview_notify
|
||||
from .sensor import async_create_preview_sensor
|
||||
from .switch import async_create_preview_switch
|
||||
from .valve import async_create_preview_valve
|
||||
|
||||
_STATISTIC_MEASURES = [
|
||||
"last",
|
||||
@@ -173,7 +172,6 @@ GROUP_TYPES = [
|
||||
"notify",
|
||||
"sensor",
|
||||
"switch",
|
||||
"valve",
|
||||
]
|
||||
|
||||
|
||||
@@ -255,11 +253,6 @@ CONFIG_FLOW = {
|
||||
preview="group",
|
||||
validate_user_input=set_group_type("switch"),
|
||||
),
|
||||
"valve": SchemaFlowFormStep(
|
||||
basic_group_config_schema("valve"),
|
||||
preview="group",
|
||||
validate_user_input=set_group_type("valve"),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -309,10 +302,6 @@ OPTIONS_FLOW = {
|
||||
partial(light_switch_options_schema, "switch"),
|
||||
preview="group",
|
||||
),
|
||||
"valve": SchemaFlowFormStep(
|
||||
partial(basic_group_options_schema, "valve"),
|
||||
preview="group",
|
||||
),
|
||||
}
|
||||
|
||||
PREVIEW_OPTIONS_SCHEMA: dict[str, vol.Schema] = {}
|
||||
@@ -332,7 +321,6 @@ CREATE_PREVIEW_ENTITY: dict[
|
||||
"notify": async_create_preview_notify,
|
||||
"sensor": async_create_preview_sensor,
|
||||
"switch": async_create_preview_switch,
|
||||
"valve": async_create_preview_valve,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -16,8 +16,7 @@
|
||||
"media_player": "Media player group",
|
||||
"notify": "Notify group",
|
||||
"sensor": "Sensor group",
|
||||
"switch": "Switch group",
|
||||
"valve": "Valve group"
|
||||
"switch": "Switch group"
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
@@ -128,18 +127,6 @@
|
||||
"data_description": {
|
||||
"all": "[%key:component::group::config::step::binary_sensor::data_description::all%]"
|
||||
}
|
||||
},
|
||||
"valve": {
|
||||
"title": "[%key:component::group::config::step::user::title%]",
|
||||
"data": {
|
||||
"all": "[%key:component::group::config::step::binary_sensor::data::all%]",
|
||||
"entities": "[%key:component::group::config::step::binary_sensor::data::entities%]",
|
||||
"hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]",
|
||||
"name": "[%key:common::config_flow::data::name%]"
|
||||
},
|
||||
"data_description": {
|
||||
"all": "[%key:component::group::config::step::binary_sensor::data_description::all%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -225,16 +212,6 @@
|
||||
"data_description": {
|
||||
"all": "[%key:component::group::config::step::binary_sensor::data_description::all%]"
|
||||
}
|
||||
},
|
||||
"valve": {
|
||||
"data": {
|
||||
"all": "[%key:component::group::config::step::binary_sensor::data::all%]",
|
||||
"entities": "[%key:component::group::config::step::binary_sensor::data::entities%]",
|
||||
"hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]"
|
||||
},
|
||||
"data_description": {
|
||||
"all": "[%key:component::group::config::step::binary_sensor::data_description::all%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
"""Platform allowing several valves to be grouped into one valve."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.valve import (
|
||||
ATTR_CURRENT_POSITION,
|
||||
ATTR_POSITION,
|
||||
DOMAIN as VALVE_DOMAIN,
|
||||
PLATFORM_SCHEMA as VALVE_PLATFORM_SCHEMA,
|
||||
ValveEntity,
|
||||
ValveEntityFeature,
|
||||
ValveState,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
CONF_ENTITIES,
|
||||
CONF_NAME,
|
||||
CONF_UNIQUE_ID,
|
||||
SERVICE_CLOSE_VALVE,
|
||||
SERVICE_OPEN_VALVE,
|
||||
SERVICE_SET_VALVE_POSITION,
|
||||
SERVICE_STOP_VALVE,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, State, callback
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
AddEntitiesCallback,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from .entity import GroupEntity
|
||||
from .util import reduce_attribute
|
||||
|
||||
KEY_OPEN_CLOSE = "open_close"
|
||||
KEY_STOP = "stop"
|
||||
KEY_SET_POSITION = "set_position"
|
||||
|
||||
DEFAULT_NAME = "Valve Group"
|
||||
|
||||
# No limit on parallel updates to enable a group calling another group
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
PLATFORM_SCHEMA = VALVE_PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_ENTITIES): cv.entities_domain(VALVE_DOMAIN),
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_UNIQUE_ID): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the Valve Group platform."""
|
||||
async_add_entities(
|
||||
[
|
||||
ValveGroup(
|
||||
config.get(CONF_UNIQUE_ID), config[CONF_NAME], config[CONF_ENTITIES]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Initialize Valve Group config entry."""
|
||||
registry = er.async_get(hass)
|
||||
entities = er.async_validate_entity_ids(
|
||||
registry, config_entry.options[CONF_ENTITIES]
|
||||
)
|
||||
|
||||
async_add_entities(
|
||||
[ValveGroup(config_entry.entry_id, config_entry.title, entities)]
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_create_preview_valve(
|
||||
hass: HomeAssistant, name: str, validated_config: dict[str, Any]
|
||||
) -> ValveGroup:
|
||||
"""Create a preview valve."""
|
||||
return ValveGroup(
|
||||
None,
|
||||
name,
|
||||
validated_config[CONF_ENTITIES],
|
||||
)
|
||||
|
||||
|
||||
class ValveGroup(GroupEntity, ValveEntity):
|
||||
"""Representation of a ValveGroup."""
|
||||
|
||||
_attr_available: bool = False
|
||||
_attr_current_valve_position: int | None = None
|
||||
_attr_is_closed: bool | None = None
|
||||
_attr_is_closing: bool | None = False
|
||||
_attr_is_opening: bool | None = False
|
||||
_attr_reports_position: bool = False
|
||||
|
||||
def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None:
|
||||
"""Initialize a ValveGroup entity."""
|
||||
self._entity_ids = entities
|
||||
self._valves: dict[str, set[str]] = {
|
||||
KEY_OPEN_CLOSE: set(),
|
||||
KEY_STOP: set(),
|
||||
KEY_SET_POSITION: set(),
|
||||
}
|
||||
|
||||
self._attr_name = name
|
||||
self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entities}
|
||||
self._attr_unique_id = unique_id
|
||||
|
||||
@callback
|
||||
def async_update_supported_features(
|
||||
self,
|
||||
entity_id: str,
|
||||
new_state: State | None,
|
||||
) -> None:
|
||||
"""Update dictionaries with supported features."""
|
||||
if not new_state:
|
||||
for values in self._valves.values():
|
||||
values.discard(entity_id)
|
||||
return
|
||||
|
||||
features = new_state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
|
||||
if features & (ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE):
|
||||
self._valves[KEY_OPEN_CLOSE].add(entity_id)
|
||||
else:
|
||||
self._valves[KEY_OPEN_CLOSE].discard(entity_id)
|
||||
if features & (ValveEntityFeature.STOP):
|
||||
self._valves[KEY_STOP].add(entity_id)
|
||||
else:
|
||||
self._valves[KEY_STOP].discard(entity_id)
|
||||
if features & (ValveEntityFeature.SET_POSITION):
|
||||
self._valves[KEY_SET_POSITION].add(entity_id)
|
||||
else:
|
||||
self._valves[KEY_SET_POSITION].discard(entity_id)
|
||||
|
||||
async def async_open_valve(self) -> None:
|
||||
"""Open the valves."""
|
||||
data = {ATTR_ENTITY_ID: self._valves[KEY_OPEN_CLOSE]}
|
||||
await self.hass.services.async_call(
|
||||
VALVE_DOMAIN, SERVICE_OPEN_VALVE, data, blocking=True, context=self._context
|
||||
)
|
||||
|
||||
async def async_handle_open_valve(self) -> None: # type: ignore[misc]
|
||||
"""Open the valves.
|
||||
|
||||
Override the base class to avoid calling the set position service
|
||||
for all valves. Transfer the service call to the base class and let
|
||||
it decide if the valve uses set position or open service.
|
||||
"""
|
||||
await self.async_open_valve()
|
||||
|
||||
async def async_close_valve(self) -> None:
|
||||
"""Close valves."""
|
||||
data = {ATTR_ENTITY_ID: self._valves[KEY_OPEN_CLOSE]}
|
||||
await self.hass.services.async_call(
|
||||
VALVE_DOMAIN,
|
||||
SERVICE_CLOSE_VALVE,
|
||||
data,
|
||||
blocking=True,
|
||||
context=self._context,
|
||||
)
|
||||
|
||||
async def async_handle_close_valve(self) -> None: # type: ignore[misc]
|
||||
"""Close the valves.
|
||||
|
||||
Override the base class to avoid calling the set position service
|
||||
for all valves. Transfer the service call to the base class and let
|
||||
it decide if the valve uses set position or close service.
|
||||
"""
|
||||
await self.async_close_valve()
|
||||
|
||||
async def async_set_valve_position(self, position: int) -> None:
|
||||
"""Move the valves to a specific position."""
|
||||
data = {
|
||||
ATTR_ENTITY_ID: self._valves[KEY_SET_POSITION],
|
||||
ATTR_POSITION: position,
|
||||
}
|
||||
await self.hass.services.async_call(
|
||||
VALVE_DOMAIN,
|
||||
SERVICE_SET_VALVE_POSITION,
|
||||
data,
|
||||
blocking=True,
|
||||
context=self._context,
|
||||
)
|
||||
|
||||
async def async_stop_valve(self) -> None:
|
||||
"""Stop the valves."""
|
||||
data = {ATTR_ENTITY_ID: self._valves[KEY_STOP]}
|
||||
await self.hass.services.async_call(
|
||||
VALVE_DOMAIN, SERVICE_STOP_VALVE, data, blocking=True, context=self._context
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_update_group_state(self) -> None:
|
||||
"""Update state and attributes."""
|
||||
states = [
|
||||
state
|
||||
for entity_id in self._entity_ids
|
||||
if (state := self.hass.states.get(entity_id)) is not None
|
||||
]
|
||||
|
||||
# Set group as unavailable if all members are unavailable or missing
|
||||
self._attr_available = any(state.state != STATE_UNAVAILABLE for state in states)
|
||||
|
||||
self._attr_is_closed = True
|
||||
self._attr_is_closing = False
|
||||
self._attr_is_opening = False
|
||||
self._attr_reports_position = False
|
||||
self._update_assumed_state_from_members()
|
||||
for state in states:
|
||||
if state.attributes.get(ATTR_CURRENT_POSITION) is not None:
|
||||
self._attr_reports_position = True
|
||||
if state.state == ValveState.OPEN:
|
||||
self._attr_is_closed = False
|
||||
continue
|
||||
if state.state == ValveState.CLOSED:
|
||||
continue
|
||||
if state.state == ValveState.CLOSING:
|
||||
self._attr_is_closing = True
|
||||
continue
|
||||
if state.state == ValveState.OPENING:
|
||||
self._attr_is_opening = True
|
||||
continue
|
||||
|
||||
valid_state = any(
|
||||
state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) for state in states
|
||||
)
|
||||
if not valid_state:
|
||||
# Set as unknown if all members are unknown or unavailable
|
||||
self._attr_is_closed = None
|
||||
|
||||
self._attr_current_valve_position = reduce_attribute(
|
||||
states, ATTR_CURRENT_POSITION
|
||||
)
|
||||
|
||||
supported_features = ValveEntityFeature(0)
|
||||
if self._valves[KEY_OPEN_CLOSE]:
|
||||
supported_features |= ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE
|
||||
if self._valves[KEY_STOP]:
|
||||
supported_features |= ValveEntityFeature.STOP
|
||||
if self._valves[KEY_SET_POSITION]:
|
||||
supported_features |= ValveEntityFeature.SET_POSITION
|
||||
self._attr_supported_features = supported_features
|
||||
@@ -36,7 +36,7 @@ DEFAULT_URL = SERVER_URLS[0]
|
||||
|
||||
DOMAIN = "growatt_server"
|
||||
|
||||
PLATFORMS = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH]
|
||||
PLATFORMS = [Platform.SENSOR, Platform.SWITCH]
|
||||
|
||||
LOGIN_INVALID_AUTH_CODE = "502"
|
||||
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
"""Number platform for Growatt."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
from growattServer import GrowattV1ApiError
|
||||
|
||||
from homeassistant.components.number import NumberEntity, NumberEntityDescription
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import GrowattConfigEntry, GrowattCoordinator
|
||||
from .sensor.sensor_entity_description import GrowattRequiredKeysMixin
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = (
|
||||
1 # Serialize updates as inverter does not handle concurrent requests
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class GrowattNumberEntityDescription(NumberEntityDescription, GrowattRequiredKeysMixin):
|
||||
"""Describes Growatt number entity."""
|
||||
|
||||
write_key: str | None = None # Parameter ID for writing (if different from api_key)
|
||||
|
||||
|
||||
# Note that the Growatt V1 API uses different keys for reading and writing parameters.
|
||||
# Reading values returns camelCase keys, while writing requires snake_case keys.
|
||||
|
||||
MIN_NUMBER_TYPES: tuple[GrowattNumberEntityDescription, ...] = (
|
||||
GrowattNumberEntityDescription(
|
||||
key="battery_charge_power_limit",
|
||||
translation_key="battery_charge_power_limit",
|
||||
api_key="chargePowerCommand", # Key returned by V1 API
|
||||
write_key="charge_power", # Key used to write parameter
|
||||
native_step=1,
|
||||
native_min_value=0,
|
||||
native_max_value=100,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
),
|
||||
GrowattNumberEntityDescription(
|
||||
key="battery_charge_soc_limit",
|
||||
translation_key="battery_charge_soc_limit",
|
||||
api_key="wchargeSOCLowLimit", # Key returned by V1 API
|
||||
write_key="charge_stop_soc", # Key used to write parameter
|
||||
native_step=1,
|
||||
native_min_value=0,
|
||||
native_max_value=100,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
),
|
||||
GrowattNumberEntityDescription(
|
||||
key="battery_discharge_power_limit",
|
||||
translation_key="battery_discharge_power_limit",
|
||||
api_key="disChargePowerCommand", # Key returned by V1 API
|
||||
write_key="discharge_power", # Key used to write parameter
|
||||
native_step=1,
|
||||
native_min_value=0,
|
||||
native_max_value=100,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
),
|
||||
GrowattNumberEntityDescription(
|
||||
key="battery_discharge_soc_limit",
|
||||
translation_key="battery_discharge_soc_limit",
|
||||
api_key="wdisChargeSOCLowLimit", # Key returned by V1 API
|
||||
write_key="discharge_stop_soc", # Key used to write parameter
|
||||
native_step=1,
|
||||
native_min_value=0,
|
||||
native_max_value=100,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: GrowattConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Growatt number entities."""
|
||||
runtime_data = entry.runtime_data
|
||||
|
||||
# Add number entities for each MIN device (only supported with V1 API)
|
||||
async_add_entities(
|
||||
GrowattNumber(device_coordinator, description)
|
||||
for device_coordinator in runtime_data.devices.values()
|
||||
if (
|
||||
device_coordinator.device_type == "min"
|
||||
and device_coordinator.api_version == "v1"
|
||||
)
|
||||
for description in MIN_NUMBER_TYPES
|
||||
)
|
||||
|
||||
|
||||
class GrowattNumber(CoordinatorEntity[GrowattCoordinator], NumberEntity):
|
||||
"""Representation of a Growatt number."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
entity_description: GrowattNumberEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: GrowattCoordinator,
|
||||
description: GrowattNumberEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the number."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.device_id}_{description.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, coordinator.device_id)},
|
||||
manufacturer="Growatt",
|
||||
name=coordinator.device_id,
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
"""Return the current value of the number."""
|
||||
value = self.coordinator.data.get(self.entity_description.api_key)
|
||||
if value is None:
|
||||
return None
|
||||
return int(value)
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set the value of the number."""
|
||||
# Use write_key if specified, otherwise fall back to api_key
|
||||
parameter_id = (
|
||||
self.entity_description.write_key or self.entity_description.api_key
|
||||
)
|
||||
int_value = int(value)
|
||||
|
||||
try:
|
||||
# Use V1 API to write parameter
|
||||
await self.hass.async_add_executor_job(
|
||||
self.coordinator.api.min_write_parameter,
|
||||
self.coordinator.device_id,
|
||||
parameter_id,
|
||||
int_value,
|
||||
)
|
||||
except GrowattV1ApiError as e:
|
||||
raise HomeAssistantError(f"Error while setting parameter: {e}") from e
|
||||
|
||||
# If no exception was raised, the write was successful
|
||||
_LOGGER.debug(
|
||||
"Set parameter %s to %s",
|
||||
parameter_id,
|
||||
value,
|
||||
)
|
||||
|
||||
# Update the value in coordinator data to avoid triggering an immediate
|
||||
# refresh that would hit the API rate limit (5-minute polling interval)
|
||||
self.coordinator.data[self.entity_description.api_key] = int_value
|
||||
self.async_write_ha_state()
|
||||
@@ -504,20 +504,6 @@
|
||||
"name": "Maximum power"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"battery_charge_power_limit": {
|
||||
"name": "Battery charge power limit"
|
||||
},
|
||||
"battery_charge_soc_limit": {
|
||||
"name": "Battery charge SOC limit"
|
||||
},
|
||||
"battery_discharge_power_limit": {
|
||||
"name": "Battery discharge power limit"
|
||||
},
|
||||
"battery_discharge_soc_limit": {
|
||||
"name": "Battery discharge SOC limit"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"ac_charge": {
|
||||
"name": "Charge from grid"
|
||||
|
||||
@@ -3,15 +3,18 @@
|
||||
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
|
||||
|
||||
from .const import HARMONY_OPTIONS_UPDATE, PLATFORMS
|
||||
from .data import HarmonyConfigEntry, HarmonyData
|
||||
if sys.version_info < (3, 14):
|
||||
from .const import HARMONY_OPTIONS_UPDATE, PLATFORMS
|
||||
from .data import HarmonyConfigEntry, HarmonyData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -22,6 +25,10 @@ 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"],
|
||||
"requirements": ["aioharmony==0.5.3;python_version<'3.14'"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Logitech",
|
||||
|
||||
@@ -109,8 +109,6 @@ DATA_KEY_HOST = "host"
|
||||
DATA_KEY_SUPERVISOR_ISSUES = "supervisor_issues"
|
||||
|
||||
PLACEHOLDER_KEY_ADDON = "addon"
|
||||
PLACEHOLDER_KEY_ADDON_INFO = "addon_info"
|
||||
PLACEHOLDER_KEY_ADDON_DOCUMENTATION = "addon_documentation"
|
||||
PLACEHOLDER_KEY_ADDON_URL = "addon_url"
|
||||
PLACEHOLDER_KEY_REFERENCE = "reference"
|
||||
PLACEHOLDER_KEY_COMPONENTS = "components"
|
||||
@@ -122,7 +120,6 @@ ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING = "issue_addon_detached_addon_missing"
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED = "issue_addon_detached_addon_removed"
|
||||
ISSUE_KEY_ADDON_PWNED = "issue_addon_pwned"
|
||||
ISSUE_KEY_SYSTEM_FREE_SPACE = "issue_system_free_space"
|
||||
ISSUE_KEY_ADDON_DEPRECATED = "issue_addon_deprecated_addon"
|
||||
|
||||
CORE_CONTAINER = "homeassistant"
|
||||
SUPERVISOR_CONTAINER = "hassio_supervisor"
|
||||
@@ -159,7 +156,6 @@ EXTRA_PLACEHOLDERS = {
|
||||
ISSUE_KEY_ADDON_PWNED: {
|
||||
"more_info_pwned": "https://www.home-assistant.io/more-info/pwned-passwords",
|
||||
},
|
||||
ISSUE_KEY_ADDON_DEPRECATED: HELP_URLS,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -43,7 +43,6 @@ from .const import (
|
||||
EVENT_SUPPORTED_CHANGED,
|
||||
EXTRA_PLACEHOLDERS,
|
||||
ISSUE_KEY_ADDON_BOOT_FAIL,
|
||||
ISSUE_KEY_ADDON_DEPRECATED,
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
|
||||
ISSUE_KEY_ADDON_PWNED,
|
||||
@@ -85,7 +84,6 @@ ISSUE_KEYS_FOR_REPAIRS = {
|
||||
"issue_system_disk_lifetime",
|
||||
ISSUE_KEY_SYSTEM_FREE_SPACE,
|
||||
ISSUE_KEY_ADDON_PWNED,
|
||||
ISSUE_KEY_ADDON_DEPRECATED,
|
||||
}
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -18,13 +18,10 @@ from . import get_addons_info, get_issues_info
|
||||
from .const import (
|
||||
EXTRA_PLACEHOLDERS,
|
||||
ISSUE_KEY_ADDON_BOOT_FAIL,
|
||||
ISSUE_KEY_ADDON_DEPRECATED,
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
|
||||
ISSUE_KEY_ADDON_PWNED,
|
||||
ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
|
||||
PLACEHOLDER_KEY_ADDON,
|
||||
PLACEHOLDER_KEY_ADDON_DOCUMENTATION,
|
||||
PLACEHOLDER_KEY_ADDON_INFO,
|
||||
PLACEHOLDER_KEY_COMPONENTS,
|
||||
PLACEHOLDER_KEY_REFERENCE,
|
||||
)
|
||||
@@ -198,23 +195,6 @@ class AddonIssueRepairFlow(SupervisorIssueRepairFlow):
|
||||
return placeholders or None
|
||||
|
||||
|
||||
class DeprecatedAddonIssueRepairFlow(AddonIssueRepairFlow):
|
||||
"""Handler for deprecated addon issue fixing flows."""
|
||||
|
||||
@property
|
||||
def description_placeholders(self) -> dict[str, str] | None:
|
||||
"""Get description placeholders for steps."""
|
||||
placeholders: dict[str, str] = super().description_placeholders or {}
|
||||
if self.issue and self.issue.reference:
|
||||
placeholders[PLACEHOLDER_KEY_ADDON_INFO] = (
|
||||
f"homeassistant://hassio/addon/{self.issue.reference}/info"
|
||||
)
|
||||
placeholders[PLACEHOLDER_KEY_ADDON_DOCUMENTATION] = (
|
||||
f"homeassistant://hassio/addon/{self.issue.reference}/documentation"
|
||||
)
|
||||
return placeholders or None
|
||||
|
||||
|
||||
async def async_create_fix_flow(
|
||||
hass: HomeAssistant,
|
||||
issue_id: str,
|
||||
@@ -225,8 +205,6 @@ async def async_create_fix_flow(
|
||||
issue = supervisor_issues and supervisor_issues.get_issue(issue_id)
|
||||
if issue and issue.key == ISSUE_KEY_SYSTEM_DOCKER_CONFIG:
|
||||
return DockerConfigIssueRepairFlow(hass, issue_id)
|
||||
if issue and issue.key == ISSUE_KEY_ADDON_DEPRECATED:
|
||||
return DeprecatedAddonIssueRepairFlow(hass, issue_id)
|
||||
if issue and issue.key in {
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
|
||||
ISSUE_KEY_ADDON_BOOT_FAIL,
|
||||
|
||||
@@ -56,19 +56,6 @@
|
||||
"title": "Insecure secrets detected in add-on configuration",
|
||||
"description": "Add-on {addon} uses secrets/passwords in its configuration which are detected as not secure. See [pwned passwords and secrets]({more_info_pwned}) for more information on this issue."
|
||||
},
|
||||
"issue_addon_deprecated_addon": {
|
||||
"title": "Installed add-on is deprecated",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"addon_execute_remove": {
|
||||
"description": "Add-on {addon} is marked deprecated by the developer. This means it is no longer being maintained and so may break or become a security issue over time.\n\nReview the [readme]({addon_info}) and [documentation]({addon_documentation}) of the add-on to see if the developer provided instructions.\n\nSelecting **Submit** will uninstall this deprecated add-on. Alternatively, you can check [Home Assistant help]({help_url}) and the [community forum]({community_url}) for alternatives to migrate to."
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"apply_suggestion_fail": "Could not uninstall the add-on. Check the Supervisor logs for more details."
|
||||
}
|
||||
}
|
||||
},
|
||||
"issue_mount_mount_failed": {
|
||||
"title": "Network storage device failed",
|
||||
"fix_flow": {
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/holiday",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["holidays==0.83", "babel==2.15.0"]
|
||||
"requirements": ["holidays==0.82", "babel==2.15.0"]
|
||||
}
|
||||
|
||||
@@ -22,6 +22,6 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aiohomeconnect"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiohomeconnect==0.23.0"],
|
||||
"requirements": ["aiohomeconnect==0.22.0"],
|
||||
"zeroconf": ["_homeconnect._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ if TYPE_CHECKING:
|
||||
|
||||
DOMAIN = ha.DOMAIN
|
||||
|
||||
DATA_EXPOSED_ENTITIES: HassKey[ExposedEntities] = HassKey(f"{DOMAIN}.exposed_entities")
|
||||
DATA_EXPOSED_ENTITIES: HassKey[ExposedEntities] = HassKey(f"{DOMAIN}.exposed_entites")
|
||||
DATA_STOP_HANDLER = f"{DOMAIN}.stop_handler"
|
||||
|
||||
SERVICE_HOMEASSISTANT_STOP: Final = "stop"
|
||||
|
||||
@@ -94,14 +94,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> boo
|
||||
for device in devices:
|
||||
# Check if the device is still present in homee
|
||||
device_identifiers = {identifier[1] for identifier in device.identifiers}
|
||||
# homee itself uses just the uid, nodes use {uid}-{nodeid}
|
||||
if homee.settings.uid in device_identifiers:
|
||||
continue # Hub itself is never removed.
|
||||
# homee itself uses just the uid, nodes use uid-nodeid
|
||||
is_homee_hub = homee.settings.uid in device_identifiers
|
||||
is_node_present = any(
|
||||
f"{homee.settings.uid}-{node.id}" in device_identifiers
|
||||
for node in homee.nodes
|
||||
)
|
||||
if not is_node_present:
|
||||
if not is_node_present and not is_homee_hub:
|
||||
_LOGGER.info("Removing device %s", device.name)
|
||||
device_registry.async_update_device(
|
||||
device_id=device.id,
|
||||
@@ -111,17 +110,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> boo
|
||||
# Remove device at runtime when node is removed in homee
|
||||
async def _remove_node_callback(node: HomeeNode, add: bool) -> None:
|
||||
"""Call when a node is removed."""
|
||||
if add:
|
||||
return
|
||||
device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, f"{entry.runtime_data.settings.uid}-{node.id}")}
|
||||
)
|
||||
if device:
|
||||
_LOGGER.info("Removing device %s", device.name)
|
||||
device_registry.async_update_device(
|
||||
device_id=device.id,
|
||||
remove_config_entry_id=entry.entry_id,
|
||||
if not add:
|
||||
device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, f"{entry.runtime_data.settings.uid}-{node.id}")}
|
||||
)
|
||||
if device:
|
||||
_LOGGER.info("Removing device %s", device.name)
|
||||
device_registry.async_update_device(
|
||||
device_id=device.id,
|
||||
remove_config_entry_id=entry.entry_id,
|
||||
)
|
||||
|
||||
homee.add_nodes_listener(_remove_node_callback)
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/homematicip_cloud",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["homematicip"],
|
||||
"requirements": ["homematicip==2.3.1"]
|
||||
"requirements": ["homematicip==2.3.0"]
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ from homeassistant.config_entries import SOURCE_REAUTH
|
||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
|
||||
@@ -72,25 +71,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry)
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
def get_main_device(
|
||||
hass: HomeAssistant, entry: HomeWizardConfigEntry
|
||||
) -> dr.DeviceEntry | None:
|
||||
"""Helper function to get the main device for the config entry."""
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entries = dr.async_entries_for_config_entry(
|
||||
device_registry, config_entry_id=entry.entry_id
|
||||
)
|
||||
|
||||
if not device_entries:
|
||||
return None
|
||||
|
||||
# Get first device that is not a sub-device, as this is the main device in HomeWizard
|
||||
# This is relevant for the P1 Meter which may create sub-devices for external utility meters
|
||||
return next(
|
||||
(device for device in device_entries if device.via_device_id is None), None
|
||||
)
|
||||
|
||||
|
||||
async def async_check_v2_support_and_create_issue(
|
||||
hass: HomeAssistant, entry: HomeWizardConfigEntry
|
||||
) -> None:
|
||||
@@ -99,16 +79,6 @@ async def async_check_v2_support_and_create_issue(
|
||||
if not await has_v2_api(entry.data[CONF_IP_ADDRESS], async_get_clientsession(hass)):
|
||||
return
|
||||
|
||||
title = entry.title
|
||||
|
||||
# Try to get the name from the device registry
|
||||
# This is to make it clearer which device needs reconfiguration, as the config entry title is kept default most of the time
|
||||
if main_device := get_main_device(hass, entry):
|
||||
device_name = main_device.name_by_user or main_device.name
|
||||
|
||||
if device_name and entry.title != device_name:
|
||||
title = f"{entry.title} ({device_name})"
|
||||
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
@@ -118,7 +88,7 @@ async def async_check_v2_support_and_create_issue(
|
||||
learn_more_url="https://home-assistant.io/integrations/homewizard/#which-button-do-i-need-to-press-to-configure-the-device",
|
||||
translation_key="migrate_to_v2_api",
|
||||
translation_placeholders={
|
||||
"title": title,
|
||||
"title": entry.title,
|
||||
},
|
||||
severity=IssueSeverity.WARNING,
|
||||
data={"entry_id": entry.entry_id},
|
||||
|
||||
@@ -177,7 +177,7 @@
|
||||
},
|
||||
"issues": {
|
||||
"migrate_to_v2_api": {
|
||||
"title": "Update the authentication method for {title}",
|
||||
"title": "Update authentication method",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"confirm": {
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/honeywell",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["somecomfort"],
|
||||
"requirements": ["AIOSomecomfort==0.0.35"]
|
||||
"requirements": ["AIOSomecomfort==0.0.33"]
|
||||
}
|
||||
|
||||
@@ -43,22 +43,18 @@ def async_setup_forwarded(
|
||||
some proxies, for example, Kubernetes NGINX ingress, only retain one element
|
||||
in the X-Forwarded-Proto header. In that case, we'll just use what we have.
|
||||
|
||||
`X-Forwarded-Host: <host1>, <host2>, <host3>`
|
||||
e.g., `X-Forwarded-Host: example.com, proxy.example.com, backend.example.com`
|
||||
OR `X-Forwarded-Host: example.com` (one entry, even with multiple proxies)
|
||||
`X-Forwarded-Host: <host>`
|
||||
e.g., `X-Forwarded-Host: example.com`
|
||||
|
||||
If the previous headers are processed successfully, and the X-Forwarded-Host is
|
||||
present, the last one in the list will be used (set by the proxy nearest to the backend).
|
||||
|
||||
Multiple headers are valid as stated in https://www.rfc-editor.org/rfc/rfc7239#section-7.1
|
||||
If multiple headers are present, they are handled according to
|
||||
https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Forwarded-For#parsing
|
||||
> "split each X-Forwarded-For header by comma into lists and then join the lists."
|
||||
present, it will be used.
|
||||
|
||||
Additionally:
|
||||
- If no X-Forwarded-For header is found, the processing of all headers is skipped.
|
||||
- Throw HTTP 400 status when untrusted connected peer provides
|
||||
X-Forwarded-For headers.
|
||||
- If multiple instances of X-Forwarded-For, X-Forwarded-Proto or
|
||||
X-Forwarded-Host are found, an HTTP 400 status code is thrown.
|
||||
- If malformed or invalid (IP) data in X-Forwarded-For header is found,
|
||||
an HTTP 400 status code is thrown.
|
||||
- The connected client peer on the socket of the incoming connection,
|
||||
@@ -115,12 +111,15 @@ def async_setup_forwarded(
|
||||
)
|
||||
raise HTTPBadRequest
|
||||
|
||||
# Process multiple X-Forwarded-For from the right side (by reversing the list)
|
||||
forwarded_for_split = list(
|
||||
reversed(
|
||||
[addr for header in forwarded_for_headers for addr in header.split(",")]
|
||||
# Multiple X-Forwarded-For headers
|
||||
if len(forwarded_for_headers) > 1:
|
||||
_LOGGER.error(
|
||||
"Too many headers for X-Forwarded-For: %s", forwarded_for_headers
|
||||
)
|
||||
)
|
||||
raise HTTPBadRequest
|
||||
|
||||
# Process X-Forwarded-For from the right side (by reversing the list)
|
||||
forwarded_for_split = list(reversed(forwarded_for_headers[0].split(",")))
|
||||
try:
|
||||
forwarded_for = [ip_address(addr.strip()) for addr in forwarded_for_split]
|
||||
except ValueError as err:
|
||||
@@ -149,15 +148,14 @@ def async_setup_forwarded(
|
||||
X_FORWARDED_PROTO, []
|
||||
)
|
||||
if forwarded_proto_headers:
|
||||
# Process multiple X-Forwarded-Proto from the right side (by reversing the list)
|
||||
forwarded_proto_split = list(
|
||||
reversed(
|
||||
[
|
||||
addr
|
||||
for header in forwarded_proto_headers
|
||||
for addr in header.split(",")
|
||||
]
|
||||
if len(forwarded_proto_headers) > 1:
|
||||
_LOGGER.error(
|
||||
"Too many headers for X-Forward-Proto: %s", forwarded_proto_headers
|
||||
)
|
||||
raise HTTPBadRequest
|
||||
|
||||
forwarded_proto_split = list(
|
||||
reversed(forwarded_proto_headers[0].split(","))
|
||||
)
|
||||
forwarded_proto = [proto.strip() for proto in forwarded_proto_split]
|
||||
|
||||
@@ -193,16 +191,14 @@ def async_setup_forwarded(
|
||||
# Handle X-Forwarded-Host
|
||||
forwarded_host_headers: list[str] = request.headers.getall(X_FORWARDED_HOST, [])
|
||||
if forwarded_host_headers:
|
||||
# Process multiple X-Forwarded-Host from the right side (by reversing the list)
|
||||
forwarded_host = list(
|
||||
reversed(
|
||||
[
|
||||
addr.strip()
|
||||
for header in forwarded_host_headers
|
||||
for addr in header.split(",")
|
||||
]
|
||||
# Multiple X-Forwarded-Host headers
|
||||
if len(forwarded_host_headers) > 1:
|
||||
_LOGGER.error(
|
||||
"Too many headers for X-Forwarded-Host: %s", forwarded_host_headers
|
||||
)
|
||||
)[0]
|
||||
raise HTTPBadRequest
|
||||
|
||||
forwarded_host = forwarded_host_headers[0].strip()
|
||||
if not forwarded_host:
|
||||
_LOGGER.error("Empty value received in X-Forward-Host header")
|
||||
raise HTTPBadRequest
|
||||
|
||||
@@ -14,7 +14,7 @@ rules:
|
||||
comment: See if we can catch more specific exceptions in get_device_info.
|
||||
dependency-transparency:
|
||||
status: todo
|
||||
comment: stringcase is not built and published to PyPI from a public CI pipeline. huawei-lte-api is from https://gitlab.salamek.cz/Mirrors/huawei-lte-api, see https://github.com/Salamek/huawei-lte-api/issues/253
|
||||
comment: huawei-lte-api and stringcase are not built and published to PyPI from a public CI pipeline.
|
||||
docs-actions: done
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
@@ -41,7 +41,7 @@ rules:
|
||||
reauthentication-flow: done
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: Get percentage up there, add missing actual action press invocations in button tests' suspended state tests.
|
||||
comment: Get percentage up there, add missing actual action press invocations in button tests' suspended state tests, rename test_switch.py to test_switch.py + make its functions receive hass as first parameter where applicable.
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
|
||||
@@ -112,7 +112,7 @@ class AutomowerButtonEntity(AutomowerControlEntity, ButtonEntity):
|
||||
self.mower_attributes
|
||||
)
|
||||
|
||||
@handle_sending_exception
|
||||
@handle_sending_exception()
|
||||
async def async_press(self) -> None:
|
||||
"""Send a command to the mower."""
|
||||
await self.entity_description.press_fn(self.coordinator.api, self.mower_id)
|
||||
|
||||
@@ -182,6 +182,14 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
|
||||
"Failed to listen to websocket. Trying to reconnect: %s",
|
||||
err,
|
||||
)
|
||||
if not hass.is_stopping:
|
||||
await asyncio.sleep(self.reconnect_time)
|
||||
self.reconnect_time = min(self.reconnect_time * 2, MAX_WS_RECONNECT_TIME)
|
||||
entry.async_create_background_task(
|
||||
hass,
|
||||
self.client_listen(hass, entry, automower_client),
|
||||
"reconnect_task",
|
||||
)
|
||||
|
||||
def _should_poll(self) -> bool:
|
||||
"""Return True if at least one mower is connected and at least one is not OFF."""
|
||||
|
||||
@@ -6,7 +6,7 @@ import asyncio
|
||||
from collections.abc import Callable, Coroutine
|
||||
import functools
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar, overload
|
||||
from typing import TYPE_CHECKING, Any, Concatenate
|
||||
|
||||
from aioautomower.exceptions import ApiError
|
||||
from aioautomower.model import MowerActivities, MowerAttributes, MowerStates, WorkArea
|
||||
@@ -37,42 +37,23 @@ ERROR_STATES = [
|
||||
]
|
||||
|
||||
|
||||
_Entity = TypeVar("_Entity", bound="AutomowerBaseEntity")
|
||||
_P = ParamSpec("_P")
|
||||
@callback
|
||||
def _work_area_translation_key(work_area_id: int, key: str) -> str:
|
||||
"""Return the translation key."""
|
||||
if work_area_id == 0:
|
||||
return f"my_lawn_{key}"
|
||||
return f"work_area_{key}"
|
||||
|
||||
|
||||
@overload
|
||||
def handle_sending_exception(
|
||||
_func: Callable[Concatenate[_Entity, _P], Coroutine[Any, Any, Any]],
|
||||
) -> Callable[Concatenate[_Entity, _P], Coroutine[Any, Any, None]]: ...
|
||||
type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], Coroutine[Any, Any, _R]]
|
||||
|
||||
|
||||
@overload
|
||||
def handle_sending_exception(
|
||||
*,
|
||||
def handle_sending_exception[_Entity: AutomowerBaseEntity, **_P](
|
||||
poll_after_sending: bool = False,
|
||||
) -> Callable[
|
||||
[Callable[Concatenate[_Entity, _P], Coroutine[Any, Any, Any]]],
|
||||
Callable[Concatenate[_Entity, _P], Coroutine[Any, Any, None]],
|
||||
]: ...
|
||||
|
||||
|
||||
def handle_sending_exception(
|
||||
_func: Callable[Concatenate[_Entity, _P], Coroutine[Any, Any, Any]] | None = None,
|
||||
*,
|
||||
poll_after_sending: bool = False,
|
||||
) -> (
|
||||
Callable[Concatenate[_Entity, _P], Coroutine[Any, Any, None]]
|
||||
| Callable[
|
||||
[Callable[Concatenate[_Entity, _P], Coroutine[Any, Any, Any]]],
|
||||
Callable[Concatenate[_Entity, _P], Coroutine[Any, Any, None]],
|
||||
]
|
||||
):
|
||||
) -> Callable[[_FuncType[_Entity, _P, Any]], _FuncType[_Entity, _P, None]]:
|
||||
"""Handle exceptions while sending a command and optionally refresh coordinator."""
|
||||
|
||||
def decorator(
|
||||
func: Callable[Concatenate[_Entity, _P], Coroutine[Any, Any, Any]],
|
||||
) -> Callable[Concatenate[_Entity, _P], Coroutine[Any, Any, None]]:
|
||||
def decorator(func: _FuncType[_Entity, _P, Any]) -> _FuncType[_Entity, _P, None]:
|
||||
@functools.wraps(func)
|
||||
async def wrapper(self: _Entity, *args: _P.args, **kwargs: _P.kwargs) -> None:
|
||||
try:
|
||||
@@ -92,20 +73,7 @@ def handle_sending_exception(
|
||||
|
||||
return wrapper
|
||||
|
||||
if _func is None:
|
||||
# call with brackets: @handle_sending_exception(...)
|
||||
return decorator
|
||||
|
||||
# call without brackets: @handle_sending_exception
|
||||
return decorator(_func)
|
||||
|
||||
|
||||
@callback
|
||||
def _work_area_translation_key(work_area_id: int, key: str) -> str:
|
||||
"""Return the translation key."""
|
||||
if work_area_id == 0:
|
||||
return f"my_lawn_{key}"
|
||||
return f"work_area_{key}"
|
||||
return decorator
|
||||
|
||||
|
||||
class AutomowerBaseEntity(CoordinatorEntity[AutomowerDataUpdateCoordinator]):
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"reset_cutting_blade_usage_time": {
|
||||
"default": "mdi:saw-blade"
|
||||
},
|
||||
"sync_clock": {
|
||||
"default": "mdi:clock-check-outline"
|
||||
},
|
||||
"reset_cutting_blade_usage_time": {
|
||||
"default": "mdi:saw-blade"
|
||||
}
|
||||
},
|
||||
"event": {
|
||||
@@ -64,17 +64,17 @@
|
||||
"on": "mdi:square"
|
||||
}
|
||||
},
|
||||
"stay_out_zones": {
|
||||
"default": "mdi:rhombus-outline",
|
||||
"state": {
|
||||
"on": "mdi:rhombus"
|
||||
}
|
||||
},
|
||||
"work_area_work_area": {
|
||||
"default": "mdi:square-outline",
|
||||
"state": {
|
||||
"on": "mdi:square"
|
||||
}
|
||||
},
|
||||
"stay_out_zones": {
|
||||
"default": "mdi:rhombus-outline",
|
||||
"state": {
|
||||
"on": "mdi:rhombus"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -135,22 +135,22 @@ class AutomowerLawnMowerEntity(AutomowerBaseEntity, LawnMowerEntity):
|
||||
"""Return the work areas of the mower."""
|
||||
return self.mower_attributes.work_areas
|
||||
|
||||
@handle_sending_exception
|
||||
@handle_sending_exception()
|
||||
async def async_start_mowing(self) -> None:
|
||||
"""Resume schedule."""
|
||||
await self.coordinator.api.commands.resume_schedule(self.mower_id)
|
||||
|
||||
@handle_sending_exception
|
||||
@handle_sending_exception()
|
||||
async def async_pause(self) -> None:
|
||||
"""Pauses the mower."""
|
||||
await self.coordinator.api.commands.pause_mowing(self.mower_id)
|
||||
|
||||
@handle_sending_exception
|
||||
@handle_sending_exception()
|
||||
async def async_dock(self) -> None:
|
||||
"""Parks the mower until next schedule."""
|
||||
await self.coordinator.api.commands.park_until_next_schedule(self.mower_id)
|
||||
|
||||
@handle_sending_exception
|
||||
@handle_sending_exception()
|
||||
async def async_override_schedule(
|
||||
self, override_mode: str, duration: timedelta
|
||||
) -> None:
|
||||
@@ -160,7 +160,7 @@ class AutomowerLawnMowerEntity(AutomowerBaseEntity, LawnMowerEntity):
|
||||
if override_mode == PARK:
|
||||
await self.coordinator.api.commands.park_for(self.mower_id, duration)
|
||||
|
||||
@handle_sending_exception
|
||||
@handle_sending_exception()
|
||||
async def async_override_schedule_work_area(
|
||||
self, work_area_id: int, duration: timedelta
|
||||
) -> None:
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aioautomower"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioautomower==2.6.0"]
|
||||
"requirements": ["aioautomower==2.2.1"]
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ class AutomowerSelectEntity(AutomowerControlEntity, SelectEntity):
|
||||
"""Return the current option for the entity."""
|
||||
return cast(HeadlightModes, self.mower_attributes.settings.headlight.mode)
|
||||
|
||||
@handle_sending_exception
|
||||
@handle_sending_exception()
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Change the selected option."""
|
||||
await self.coordinator.api.commands.set_headlight_mode(
|
||||
|
||||
@@ -123,13 +123,6 @@ def _get_current_work_area_name(data: MowerAttributes) -> str:
|
||||
return STATE_NO_WORK_AREA_ACTIVE
|
||||
|
||||
|
||||
@callback
|
||||
def _get_remaining_charging_time(data: MowerAttributes) -> int | None:
|
||||
if data.battery.remaining_charging_time is not None:
|
||||
return int(data.battery.remaining_charging_time.total_seconds())
|
||||
return None
|
||||
|
||||
|
||||
@callback
|
||||
def _get_current_work_area_dict(data: MowerAttributes) -> Mapping[str, Any]:
|
||||
"""Return the name of the current work area."""
|
||||
@@ -326,15 +319,6 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = (
|
||||
option_fn=_get_work_area_names,
|
||||
value_fn=_get_current_work_area_name,
|
||||
),
|
||||
AutomowerSensorEntityDescription(
|
||||
key="remaining_charging_time",
|
||||
translation_key="remaining_charging_time",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
value_fn=_get_remaining_charging_time,
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
suggested_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
suggested_display_precision=0,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
|
||||
"missing_amc_scope": "The `Authentication API` and the `Automower Connect API` are not connected to your application in the Husqvarna Developer Portal.",
|
||||
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
||||
"missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]",
|
||||
"no_mower_connected": "No mowers connected to this account.",
|
||||
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
|
||||
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
|
||||
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
|
||||
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
|
||||
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
|
||||
"wrong_account": "You can only reauthenticate this entry with the same Husqvarna account."
|
||||
},
|
||||
"create_entry": {
|
||||
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
||||
},
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"title": "[%key:common::config_flow::title::reauth%]",
|
||||
"description": "The Husqvarna Automower integration needs to re-authenticate your account"
|
||||
},
|
||||
"missing_scope": {
|
||||
"description": "For the best experience with this integration both the `Authentication API` and the `Automower Connect API` should be connected. Please make sure that both of them are connected to your account in the [Husqvarna Developer Portal]({application_url}).",
|
||||
"title": "Your account is missing some API connections"
|
||||
"title": "Your account is missing some API connections",
|
||||
"description": "For the best experience with this integration both the `Authentication API` and the `Automower Connect API` should be connected. Please make sure that both of them are connected to your account in the [Husqvarna Developer Portal]({application_url})."
|
||||
},
|
||||
"pick_implementation": {
|
||||
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]",
|
||||
"data": {
|
||||
"implementation": "[%key:common::config_flow::data::implementation%]"
|
||||
},
|
||||
"data_description": {
|
||||
"implementation": "[%key:common::config_flow::description::implementation%]"
|
||||
},
|
||||
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"description": "The Husqvarna Automower integration needs to re-authenticate your account",
|
||||
"title": "[%key:common::config_flow::title::reauth%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
|
||||
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
|
||||
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
||||
"missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]",
|
||||
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
|
||||
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
|
||||
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
|
||||
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
|
||||
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"wrong_account": "You can only reauthenticate this entry with the same Husqvarna account.",
|
||||
"no_mower_connected": "No mowers connected to this account.",
|
||||
"missing_amc_scope": "The `Authentication API` and the `Automower Connect API` are not connected to your application in the Husqvarna Developer Portal.",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"create_entry": {
|
||||
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
@@ -51,11 +51,11 @@
|
||||
"confirm_error": {
|
||||
"name": "Confirm error"
|
||||
},
|
||||
"reset_cutting_blade_usage_time": {
|
||||
"name": "Reset cutting blade usage time"
|
||||
},
|
||||
"sync_clock": {
|
||||
"name": "Sync clock"
|
||||
},
|
||||
"reset_cutting_blade_usage_time": {
|
||||
"name": "Reset cutting blade usage time"
|
||||
}
|
||||
},
|
||||
"event": {
|
||||
@@ -92,10 +92,10 @@
|
||||
"cutting_drive_motor_2_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_drive_motor_2_defect%]",
|
||||
"cutting_drive_motor_3_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_drive_motor_3_defect%]",
|
||||
"cutting_height_blocked": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_height_blocked%]",
|
||||
"cutting_height_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_height_problem%]",
|
||||
"cutting_height_problem_curr": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_height_problem_curr%]",
|
||||
"cutting_height_problem_dir": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_height_problem_dir%]",
|
||||
"cutting_height_problem_drive": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_height_problem_drive%]",
|
||||
"cutting_height_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_height_problem%]",
|
||||
"cutting_motor_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_motor_problem%]",
|
||||
"cutting_stopped_slope_too_steep": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_stopped_slope_too_steep%]",
|
||||
"cutting_system_blocked": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_system_blocked%]",
|
||||
@@ -106,8 +106,8 @@
|
||||
"docking_sensor_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::docking_sensor_defect%]",
|
||||
"electronic_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::electronic_problem%]",
|
||||
"empty_battery": "[%key:component::husqvarna_automower::entity::sensor::error::state::empty_battery%]",
|
||||
"error": "[%key:common::state::error%]",
|
||||
"error_at_power_up": "[%key:component::husqvarna_automower::entity::sensor::error::state::error_at_power_up%]",
|
||||
"error": "[%key:common::state::error%]",
|
||||
"fatal_error": "[%key:component::husqvarna_automower::entity::sensor::error::state::fatal_error%]",
|
||||
"folding_cutting_deck_sensor_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::folding_cutting_deck_sensor_defect%]",
|
||||
"folding_sensor_activated": "[%key:component::husqvarna_automower::entity::sensor::error::state::folding_sensor_activated%]",
|
||||
@@ -198,13 +198,13 @@
|
||||
},
|
||||
"severity": {
|
||||
"state": {
|
||||
"debug": "Debug",
|
||||
"error": "[%key:common::state::error%]",
|
||||
"fatal": "Fatal",
|
||||
"error": "[%key:common::state::error%]",
|
||||
"warning": "Warning",
|
||||
"info": "Info",
|
||||
"debug": "Debug",
|
||||
"sw": "Software",
|
||||
"unknown": "Unknown",
|
||||
"warning": "Warning"
|
||||
"unknown": "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -225,20 +225,14 @@
|
||||
"headlight_mode": {
|
||||
"name": "Headlight mode",
|
||||
"state": {
|
||||
"always_off": "Always off",
|
||||
"always_on": "Always on",
|
||||
"evening_and_night": "Evening and night",
|
||||
"evening_only": "Evening only"
|
||||
"always_off": "Always off",
|
||||
"evening_only": "Evening only",
|
||||
"evening_and_night": "Evening and night"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"cutting_blade_usage_time": {
|
||||
"name": "Cutting blade usage time"
|
||||
},
|
||||
"downtime": {
|
||||
"name": "Downtime"
|
||||
},
|
||||
"error": {
|
||||
"name": "Error",
|
||||
"state": {
|
||||
@@ -270,10 +264,10 @@
|
||||
"cutting_drive_motor_2_defect": "Cutting drive motor 2 defect",
|
||||
"cutting_drive_motor_3_defect": "Cutting drive motor 3 defect",
|
||||
"cutting_height_blocked": "Cutting height blocked",
|
||||
"cutting_height_problem": "Cutting height problem",
|
||||
"cutting_height_problem_curr": "Cutting height problem, curr",
|
||||
"cutting_height_problem_dir": "Cutting height problem, dir",
|
||||
"cutting_height_problem_drive": "Cutting height problem, drive",
|
||||
"cutting_height_problem": "Cutting height problem",
|
||||
"cutting_motor_problem": "Cutting motor problem",
|
||||
"cutting_stopped_slope_too_steep": "Cutting stopped - slope too steep",
|
||||
"cutting_system_blocked": "Cutting system blocked",
|
||||
@@ -284,8 +278,8 @@
|
||||
"docking_sensor_defect": "Docking sensor defect",
|
||||
"electronic_problem": "Electronic problem",
|
||||
"empty_battery": "Empty battery",
|
||||
"error": "[%key:common::state::error%]",
|
||||
"error_at_power_up": "Error at power up",
|
||||
"error": "[%key:common::state::error%]",
|
||||
"fatal_error": "Fatal error",
|
||||
"folding_cutting_deck_sensor_defect": "Folding cutting deck sensor defect",
|
||||
"folding_sensor_activated": "Folding sensor activated",
|
||||
@@ -382,33 +376,23 @@
|
||||
"searching_for_satellites": "Searching for satellites"
|
||||
}
|
||||
},
|
||||
"mode": {
|
||||
"name": "Mode",
|
||||
"state": {
|
||||
"demo": "Demo",
|
||||
"home": "Home",
|
||||
"main_area": "Main area",
|
||||
"poi": "Point of interest",
|
||||
"secondary_area": "Secondary area"
|
||||
}
|
||||
},
|
||||
"my_lawn_last_time_completed": {
|
||||
"name": "My lawn last time completed"
|
||||
},
|
||||
"my_lawn_progress": {
|
||||
"name": "My lawn progress"
|
||||
},
|
||||
"next_start_timestamp": {
|
||||
"name": "Next start"
|
||||
},
|
||||
"number_of_charging_cycles": {
|
||||
"name": "Number of charging cycles"
|
||||
},
|
||||
"number_of_collisions": {
|
||||
"name": "Number of collisions"
|
||||
},
|
||||
"remaining_charging_time": {
|
||||
"name": "Remaining charging time"
|
||||
"cutting_blade_usage_time": {
|
||||
"name": "Cutting blade usage time"
|
||||
},
|
||||
"downtime": {
|
||||
"name": "Downtime"
|
||||
},
|
||||
"restricted_reason": {
|
||||
"name": "Restricted reason",
|
||||
@@ -423,17 +407,17 @@
|
||||
"gardena_smart_system": "Gardena Smart System",
|
||||
"google_assistant": "Google Assistant",
|
||||
"home_assistant": "Home Assistant",
|
||||
"ifttt": "IFTTT",
|
||||
"ifttt_applets": "IFTTT applets",
|
||||
"ifttt_calendar_connection": "IFTTT calendar connection",
|
||||
"ifttt": "IFTTT",
|
||||
"none": "No restrictions",
|
||||
"not_applicable": "Not applicable",
|
||||
"park_override": "Park override",
|
||||
"sensor": "Weather timer",
|
||||
"smart_routine": "Generic smart routine",
|
||||
"smart_routine_frost_guard": "Frost guard",
|
||||
"smart_routine_rain_guard": "Rain guard",
|
||||
"smart_routine_wildlife_protection": "Wildlife protection",
|
||||
"smart_routine": "Generic smart routine",
|
||||
"week_schedule": "Week schedule"
|
||||
}
|
||||
},
|
||||
@@ -443,15 +427,27 @@
|
||||
"total_cutting_time": {
|
||||
"name": "Total cutting time"
|
||||
},
|
||||
"total_drive_distance": {
|
||||
"name": "Total drive distance"
|
||||
},
|
||||
"total_running_time": {
|
||||
"name": "Total running time"
|
||||
},
|
||||
"total_searching_time": {
|
||||
"name": "Total searching time"
|
||||
},
|
||||
"total_drive_distance": {
|
||||
"name": "Total drive distance"
|
||||
},
|
||||
"next_start_timestamp": {
|
||||
"name": "Next start"
|
||||
},
|
||||
"mode": {
|
||||
"name": "Mode",
|
||||
"state": {
|
||||
"main_area": "Main area",
|
||||
"secondary_area": "Secondary area",
|
||||
"home": "Home",
|
||||
"demo": "Demo"
|
||||
}
|
||||
},
|
||||
"uptime": {
|
||||
"name": "Uptime"
|
||||
},
|
||||
@@ -478,11 +474,11 @@
|
||||
"enable_schedule": {
|
||||
"name": "Enable schedule"
|
||||
},
|
||||
"my_lawn_work_area": {
|
||||
"name": "My lawn"
|
||||
},
|
||||
"stay_out_zones": {
|
||||
"name": "Avoid {stay_out_zone}"
|
||||
},
|
||||
"my_lawn_work_area": {
|
||||
"name": "My lawn"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -490,11 +486,11 @@
|
||||
"command_send_failed": {
|
||||
"message": "Failed to send command: {exception}"
|
||||
},
|
||||
"work_area_not_existing": {
|
||||
"message": "The selected work area does not exist."
|
||||
},
|
||||
"work_areas_not_supported": {
|
||||
"message": "This mower does not support work areas."
|
||||
},
|
||||
"work_area_not_existing": {
|
||||
"message": "The selected work area does not exist."
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
@@ -507,32 +503,32 @@
|
||||
},
|
||||
"services": {
|
||||
"override_schedule": {
|
||||
"name": "Override schedule",
|
||||
"description": "Lets the mower either mow or park for a given duration, overriding all schedules.",
|
||||
"fields": {
|
||||
"duration": {
|
||||
"description": "Minimum: 1 minute, maximum: 42 days, seconds will be ignored.",
|
||||
"name": "Duration"
|
||||
"name": "Duration",
|
||||
"description": "Minimum: 1 minute, maximum: 42 days, seconds will be ignored."
|
||||
},
|
||||
"override_mode": {
|
||||
"description": "With which action the schedule should be overridden.",
|
||||
"name": "Override mode"
|
||||
"name": "Override mode",
|
||||
"description": "With which action the schedule should be overridden."
|
||||
}
|
||||
},
|
||||
"name": "Override schedule"
|
||||
}
|
||||
},
|
||||
"override_schedule_work_area": {
|
||||
"name": "Override schedule work area",
|
||||
"description": "Lets the mower mow for a given duration in a specified work area, overriding all schedules.",
|
||||
"fields": {
|
||||
"duration": {
|
||||
"description": "[%key:component::husqvarna_automower::services::override_schedule::fields::duration::description%]",
|
||||
"name": "[%key:component::husqvarna_automower::services::override_schedule::fields::duration::name%]"
|
||||
"name": "[%key:component::husqvarna_automower::services::override_schedule::fields::duration::name%]",
|
||||
"description": "[%key:component::husqvarna_automower::services::override_schedule::fields::duration::description%]"
|
||||
},
|
||||
"work_area_id": {
|
||||
"description": "In which work area the mower should mow.",
|
||||
"name": "Work area ID"
|
||||
"name": "Work area ID",
|
||||
"description": "In which work area the mower should mow."
|
||||
}
|
||||
},
|
||||
"name": "Override schedule work area"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,12 +108,12 @@ class AutomowerScheduleSwitchEntity(AutomowerControlEntity, SwitchEntity):
|
||||
"""Return the state of the switch."""
|
||||
return self.mower_attributes.mower.mode != MowerModes.HOME
|
||||
|
||||
@handle_sending_exception
|
||||
@handle_sending_exception()
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the entity off."""
|
||||
await self.coordinator.api.commands.park_until_further_notice(self.mower_id)
|
||||
|
||||
@handle_sending_exception
|
||||
@handle_sending_exception()
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the entity on."""
|
||||
await self.coordinator.api.commands.resume_schedule(self.mower_id)
|
||||
|
||||
@@ -18,7 +18,6 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import CONFIG_DEFAULT_MAX_TEMP, CONFIG_DEFAULT_MIN_TEMP
|
||||
from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator
|
||||
from .entity import HuumBaseEntity
|
||||
|
||||
@@ -56,12 +55,12 @@ class HuumDevice(HuumBaseEntity, ClimateEntity):
|
||||
@property
|
||||
def min_temp(self) -> int:
|
||||
"""Return configured minimal temperature."""
|
||||
return self.coordinator.data.sauna_config.min_temp or CONFIG_DEFAULT_MIN_TEMP
|
||||
return self.coordinator.data.sauna_config.min_temp
|
||||
|
||||
@property
|
||||
def max_temp(self) -> int:
|
||||
"""Return configured maximum temperature."""
|
||||
return self.coordinator.data.sauna_config.max_temp or CONFIG_DEFAULT_MAX_TEMP
|
||||
return self.coordinator.data.sauna_config.max_temp
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
|
||||
@@ -9,6 +9,3 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.LIGHT, Platform.
|
||||
CONFIG_STEAMER = 1
|
||||
CONFIG_LIGHT = 2
|
||||
CONFIG_STEAMER_AND_LIGHT = 3
|
||||
|
||||
CONFIG_DEFAULT_MIN_TEMP = 40
|
||||
CONFIG_DEFAULT_MAX_TEMP = 110
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
"""The iNELS integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from inelsmqtt import InelsMqtt
|
||||
from inelsmqtt.devices import Device
|
||||
from inelsmqtt.discovery import InelsDiscovery
|
||||
|
||||
from homeassistant.components import mqtt as ha_mqtt
|
||||
from homeassistant.components.mqtt import (
|
||||
ReceiveMessage,
|
||||
async_prepare_subscribe_topics,
|
||||
async_subscribe_topics,
|
||||
async_unsubscribe_topics,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import LOGGER, PLATFORMS
|
||||
|
||||
type InelsConfigEntry = ConfigEntry[InelsData]
|
||||
|
||||
|
||||
@dataclass
|
||||
class InelsData:
|
||||
"""Represents the data structure for INELS runtime data."""
|
||||
|
||||
mqtt: InelsMqtt
|
||||
devices: list[Device]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: InelsConfigEntry) -> bool:
|
||||
"""Set up iNELS from a config entry."""
|
||||
|
||||
async def mqtt_publish(topic: str, payload: str, qos: int, retain: bool) -> None:
|
||||
"""Publish an MQTT message using the Home Assistant MQTT client."""
|
||||
await ha_mqtt.async_publish(hass, topic, payload, qos, retain)
|
||||
|
||||
async def mqtt_subscribe(
|
||||
sub_state: dict[str, Any] | None,
|
||||
topic: str,
|
||||
callback_func: Callable[[str, str], None],
|
||||
) -> dict[str, Any]:
|
||||
"""Subscribe to MQTT topics using the Home Assistant MQTT client."""
|
||||
|
||||
@callback
|
||||
def mqtt_message_received(msg: ReceiveMessage) -> None:
|
||||
"""Handle iNELS mqtt messages."""
|
||||
# Payload is always str at runtime since we don't set encoding=None
|
||||
# HA uses UTF-8 by default
|
||||
callback_func(msg.topic, msg.payload) # type: ignore[arg-type]
|
||||
|
||||
topics = {
|
||||
"inels_subscribe_topic": {
|
||||
"topic": topic,
|
||||
"msg_callback": mqtt_message_received,
|
||||
}
|
||||
}
|
||||
|
||||
sub_state = async_prepare_subscribe_topics(hass, sub_state, topics)
|
||||
await async_subscribe_topics(hass, sub_state)
|
||||
return sub_state
|
||||
|
||||
async def mqtt_unsubscribe(sub_state: dict[str, Any]) -> None:
|
||||
async_unsubscribe_topics(hass, sub_state)
|
||||
|
||||
if not await ha_mqtt.async_wait_for_mqtt_client(hass):
|
||||
LOGGER.error("MQTT integration not available")
|
||||
raise ConfigEntryNotReady("MQTT integration not available")
|
||||
|
||||
inels_mqtt = InelsMqtt(mqtt_publish, mqtt_subscribe, mqtt_unsubscribe)
|
||||
devices: list[Device] = await InelsDiscovery(inels_mqtt).start()
|
||||
|
||||
# If no devices are discovered, continue with the setup
|
||||
if not devices:
|
||||
LOGGER.info("No devices discovered")
|
||||
|
||||
entry.runtime_data = InelsData(mqtt=inels_mqtt, devices=devices)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: InelsConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
await entry.runtime_data.mqtt.unsubscribe_topics()
|
||||
entry.runtime_data.mqtt.unsubscribe_listeners()
|
||||
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user