forked from home-assistant/core
Compare commits
172 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d59a91a905 | |||
| 298f059488 | |||
| 7a5525951d | |||
| 9a9514d53b | |||
| 5337ab2e72 | |||
| b815899fdc | |||
| 81a669c163 | |||
| 188def51c6 | |||
| eb345971b4 | |||
| 9288dce7ed | |||
| 4867d3a187 | |||
| c40771ba6a | |||
| 2fc489d17d | |||
| 279785b22e | |||
| e5c986171b | |||
| 58805f721c | |||
| 29989e9034 | |||
| fbd031a03d | |||
| fe1ce39831 | |||
| 914c6459dc | |||
| 43ffdd0eef | |||
| 39d16ed5ce | |||
| 07f3d939e3 | |||
| eda60073ee | |||
| 09ffa38ddf | |||
| b32a791ea4 | |||
| a4ea25631a | |||
| bd8ea646a9 | |||
| 538a2ea057 | |||
| b461bc2fb5 | |||
| 103960e0a7 | |||
| 1c4273ce91 | |||
| 0f0209d4bb | |||
| 27b8b8458b | |||
| c022d91baa | |||
| 0daac09008 | |||
| ca8416fe50 | |||
| a14f6faaaf | |||
| a9a14381d3 | |||
| a4d0794fe4 | |||
| 9ead6fe362 | |||
| 017679abe1 | |||
| 0bd7b793fe | |||
| c46a70fdcf | |||
| 8c2ec5e7c8 | |||
| 3063f0b565 | |||
| aafc1ff074 | |||
| 45142b0cc0 | |||
| a412acec0e | |||
| ac4bd32137 | |||
| 7e1e63374f | |||
| 03fd6a901b | |||
| 46b2830699 | |||
| b416ae1387 | |||
| 962b880146 | |||
| 9c98125d20 | |||
| c9f1fee6bb | |||
| 9b8ed9643f | |||
| 7ea7178aa9 | |||
| c5746291cc | |||
| 1af384bc0a | |||
| ea82c1b73e | |||
| 96936f5f4a | |||
| 316f93f208 | |||
| f719a14537 | |||
| a830a14342 | |||
| 1b67d51e24 | |||
| e1f6475623 | |||
| 59a3fe857b | |||
| f364e29148 | |||
| 47190e4ac1 | |||
| 7fa1983da0 | |||
| 9b906e94c7 | |||
| 5ac4d5bef7 | |||
| 995e222959 | |||
| 61ac8e7e8c | |||
| 67ec71031d | |||
| 59f866bcf7 | |||
| d75d970fc7 | |||
| 0a13516ddd | |||
| 21aca3c146 | |||
| faf9c2ee40 | |||
| e89a1da462 | |||
| 8ace126d9f | |||
| ca6bae6b15 | |||
| c9ba267fec | |||
| 0e79c17cb8 | |||
| 4cb413521d | |||
| f97439eaab | |||
| 568b637dc5 | |||
| 3a8f71a64a | |||
| fea3dfda94 | |||
| 554cdd1784 | |||
| ce7a0650e4 | |||
| 5895aa4cde | |||
| bd5477729a | |||
| 2e21ac7001 | |||
| ab6394b26c | |||
| 0ae4a9a911 | |||
| f709989717 | |||
| 952363eca3 | |||
| a7995e0093 | |||
| 1064ef9dc6 | |||
| c2f06fbd47 | |||
| a36fd09644 | |||
| b89995a79f | |||
| c908f823c5 | |||
| 229c32b0da | |||
| e303a9a2b5 | |||
| 54fa30c2b8 | |||
| fbd6cf7244 | |||
| c10175e25c | |||
| 82f0e8cc19 | |||
| 623e1b08b8 | |||
| 0c73251004 | |||
| d9057fc43e | |||
| 077c9e62b4 | |||
| 7456ce1c01 | |||
| a627fa70a7 | |||
| c402eaec3f | |||
| ea51ecd384 | |||
| 0873d27d7b | |||
| 45fd7fb6d5 | |||
| e22685640c | |||
| 5756166545 | |||
| 2f8a92c725 | |||
| cf9ccc6fb4 | |||
| b05b9b9a33 | |||
| 352d5d14a3 | |||
| 52e47f55c8 | |||
| 0470bff9a2 | |||
| a38839b420 | |||
| 394b2be40a | |||
| 291dd6dc66 | |||
| ef87366346 | |||
| bd243f68a4 | |||
| 951baa3972 | |||
| 1874eec8b3 | |||
| 3120a90f26 | |||
| 7032361bf5 | |||
| bd786b53ee | |||
| f6a9cd38c0 | |||
| 1a909d3a8a | |||
| b84ae2abc3 | |||
| 15b80c59fc | |||
| c11bdcc949 | |||
| 1957ab1ccf | |||
| ef2af44795 | |||
| f0e8360401 | |||
| 03fb136218 | |||
| d415b7bc8d | |||
| 9242b67e0d | |||
| 6e7d095831 | |||
| ef05133a66 | |||
| 7b2fc282e5 | |||
| 4ca17dbb9e | |||
| 5d7a22fa76 | |||
| 502fbe65ee | |||
| ce83071900 | |||
| 4f1e9b2338 | |||
| f23bc51b88 | |||
| 44150e9fd7 | |||
| cf9686a802 | |||
| 657e5b73b6 | |||
| d3666ecf8a | |||
| bed186cce4 | |||
| 2b8240746a | |||
| efabb82cb6 | |||
| 80955ba821 | |||
| bb371c87d5 | |||
| 7ce563b0b4 | |||
| c2f6e5036e |
@@ -40,7 +40,7 @@ env:
|
||||
CACHE_VERSION: 11
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 9
|
||||
HA_SHORT_VERSION: "2025.2"
|
||||
HA_SHORT_VERSION: "2025.1"
|
||||
DEFAULT_PYTHON: "3.12"
|
||||
ALL_PYTHON_VERSIONS: "['3.12', '3.13']"
|
||||
# 10.3 is the oldest supported version
|
||||
|
||||
@@ -76,20 +76,8 @@ jobs:
|
||||
|
||||
# Use C-Extension for SQLAlchemy
|
||||
echo "REQUIRE_SQLALCHEMY_CEXT=1"
|
||||
|
||||
# Add additional pip wheel build constraints
|
||||
echo "PIP_CONSTRAINT=build_constraints.txt"
|
||||
) > .env_file
|
||||
|
||||
- name: Write pip wheel build constraints
|
||||
run: |
|
||||
(
|
||||
# ninja 1.11.1.2 + 1.11.1.3 seem to be broken on at least armhf
|
||||
# this caused the numpy builds to fail
|
||||
# https://github.com/scikit-build/ninja-python-distributions/issues/274
|
||||
echo "ninja==1.11.1.1"
|
||||
) > build_constraints.txt
|
||||
|
||||
- name: Upload env_file
|
||||
uses: actions/upload-artifact@v4.5.0
|
||||
with:
|
||||
@@ -98,13 +86,6 @@ jobs:
|
||||
include-hidden-files: true
|
||||
overwrite: true
|
||||
|
||||
- name: Upload build_constraints
|
||||
uses: actions/upload-artifact@v4.5.0
|
||||
with:
|
||||
name: build_constraints
|
||||
path: ./build_constraints.txt
|
||||
overwrite: true
|
||||
|
||||
- name: Upload requirements_diff
|
||||
uses: actions/upload-artifact@v4.5.0
|
||||
with:
|
||||
@@ -142,11 +123,6 @@ jobs:
|
||||
with:
|
||||
name: env_file
|
||||
|
||||
- name: Download build_constraints
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
with:
|
||||
name: build_constraints
|
||||
|
||||
- name: Download requirements_diff
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
with:
|
||||
@@ -166,7 +142,7 @@ jobs:
|
||||
arch: ${{ matrix.arch }}
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||
env-file: true
|
||||
apk: "libffi-dev;openssl-dev;yaml-dev;nasm;zlib-ng-dev"
|
||||
apk: "libffi-dev;openssl-dev;yaml-dev;nasm;zlib-dev"
|
||||
skip-binary: aiohttp;multidict;propcache;yarl;SQLAlchemy
|
||||
constraints: "homeassistant/package_constraints.txt"
|
||||
requirements-diff: "requirements_diff.txt"
|
||||
@@ -191,11 +167,6 @@ jobs:
|
||||
with:
|
||||
name: env_file
|
||||
|
||||
- name: Download build_constraints
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
with:
|
||||
name: build_constraints
|
||||
|
||||
- name: Download requirements_diff
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
with:
|
||||
@@ -234,7 +205,7 @@ jobs:
|
||||
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"
|
||||
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-dev"
|
||||
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
|
||||
constraints: "homeassistant/package_constraints.txt"
|
||||
requirements-diff: "requirements_diff.txt"
|
||||
@@ -248,7 +219,7 @@ jobs:
|
||||
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"
|
||||
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-dev"
|
||||
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
|
||||
constraints: "homeassistant/package_constraints.txt"
|
||||
requirements-diff: "requirements_diff.txt"
|
||||
@@ -262,7 +233,7 @@ jobs:
|
||||
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"
|
||||
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-dev"
|
||||
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
|
||||
constraints: "homeassistant/package_constraints.txt"
|
||||
requirements-diff: "requirements_diff.txt"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.8.6
|
||||
rev: v0.8.3
|
||||
hooks:
|
||||
- id: ruff
|
||||
args:
|
||||
|
||||
@@ -311,7 +311,6 @@ homeassistant.components.manual.*
|
||||
homeassistant.components.mastodon.*
|
||||
homeassistant.components.matrix.*
|
||||
homeassistant.components.matter.*
|
||||
homeassistant.components.mcp_server.*
|
||||
homeassistant.components.mealie.*
|
||||
homeassistant.components.media_extractor.*
|
||||
homeassistant.components.media_player.*
|
||||
@@ -363,9 +362,7 @@ homeassistant.components.openuv.*
|
||||
homeassistant.components.oralb.*
|
||||
homeassistant.components.otbr.*
|
||||
homeassistant.components.overkiz.*
|
||||
homeassistant.components.overseerr.*
|
||||
homeassistant.components.p1_monitor.*
|
||||
homeassistant.components.pandora.*
|
||||
homeassistant.components.panel_custom.*
|
||||
homeassistant.components.peblar.*
|
||||
homeassistant.components.peco.*
|
||||
@@ -383,7 +380,6 @@ homeassistant.components.pure_energie.*
|
||||
homeassistant.components.purpleair.*
|
||||
homeassistant.components.pushbullet.*
|
||||
homeassistant.components.pvoutput.*
|
||||
homeassistant.components.python_script.*
|
||||
homeassistant.components.qnap_qsw.*
|
||||
homeassistant.components.rabbitair.*
|
||||
homeassistant.components.radarr.*
|
||||
|
||||
+8
-16
@@ -637,8 +637,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/homeassistant_sky_connect/ @home-assistant/core
|
||||
/homeassistant/components/homeassistant_yellow/ @home-assistant/core
|
||||
/tests/components/homeassistant_yellow/ @home-assistant/core
|
||||
/homeassistant/components/homee/ @Taraman17
|
||||
/tests/components/homee/ @Taraman17
|
||||
/homeassistant/components/homekit/ @bdraco
|
||||
/tests/components/homekit/ @bdraco
|
||||
/homeassistant/components/homekit_controller/ @Jc2k @bdraco
|
||||
@@ -688,8 +686,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/icloud/ @Quentame @nzapponi
|
||||
/homeassistant/components/idasen_desk/ @abmantis
|
||||
/tests/components/idasen_desk/ @abmantis
|
||||
/homeassistant/components/igloohome/ @keithle888
|
||||
/tests/components/igloohome/ @keithle888
|
||||
/homeassistant/components/ign_sismologia/ @exxamalte
|
||||
/tests/components/ign_sismologia/ @exxamalte
|
||||
/homeassistant/components/image/ @home-assistant/core
|
||||
@@ -891,8 +887,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/matrix/ @PaarthShah
|
||||
/homeassistant/components/matter/ @home-assistant/matter
|
||||
/tests/components/matter/ @home-assistant/matter
|
||||
/homeassistant/components/mcp_server/ @allenporter
|
||||
/tests/components/mcp_server/ @allenporter
|
||||
/homeassistant/components/mealie/ @joostlek @andrew-codechimp
|
||||
/tests/components/mealie/ @joostlek @andrew-codechimp
|
||||
/homeassistant/components/meater/ @Sotolotl @emontnemery
|
||||
@@ -1109,10 +1103,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/otbr/ @home-assistant/core
|
||||
/homeassistant/components/ourgroceries/ @OnFreund
|
||||
/tests/components/ourgroceries/ @OnFreund
|
||||
/homeassistant/components/overkiz/ @imicknl
|
||||
/tests/components/overkiz/ @imicknl
|
||||
/homeassistant/components/overseerr/ @joostlek
|
||||
/tests/components/overseerr/ @joostlek
|
||||
/homeassistant/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev @tronix117 @alexfp14
|
||||
/tests/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev @tronix117 @alexfp14
|
||||
/homeassistant/components/ovo_energy/ @timmo001
|
||||
/tests/components/ovo_energy/ @timmo001
|
||||
/homeassistant/components/p1_monitor/ @klaasnicolaas
|
||||
@@ -1143,8 +1135,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/plaato/ @JohNan
|
||||
/homeassistant/components/plex/ @jjlawren
|
||||
/tests/components/plex/ @jjlawren
|
||||
/homeassistant/components/plugwise/ @CoMPaTech @bouwew
|
||||
/tests/components/plugwise/ @CoMPaTech @bouwew
|
||||
/homeassistant/components/plugwise/ @CoMPaTech @bouwew @frenck
|
||||
/tests/components/plugwise/ @CoMPaTech @bouwew @frenck
|
||||
/homeassistant/components/plum_lightpad/ @ColinHarrington @prystupa
|
||||
/tests/components/plum_lightpad/ @ColinHarrington @prystupa
|
||||
/homeassistant/components/point/ @fredrike
|
||||
@@ -1486,8 +1478,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/system_bridge/ @timmo001
|
||||
/homeassistant/components/systemmonitor/ @gjohansson-ST
|
||||
/tests/components/systemmonitor/ @gjohansson-ST
|
||||
/homeassistant/components/tado/ @erwindouna
|
||||
/tests/components/tado/ @erwindouna
|
||||
/homeassistant/components/tado/ @chiefdragon @erwindouna
|
||||
/tests/components/tado/ @chiefdragon @erwindouna
|
||||
/homeassistant/components/tag/ @balloob @dmulcahey
|
||||
/tests/components/tag/ @balloob @dmulcahey
|
||||
/homeassistant/components/tailscale/ @frenck
|
||||
@@ -1581,8 +1573,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/triggercmd/ @rvmey
|
||||
/homeassistant/components/tts/ @home-assistant/core
|
||||
/tests/components/tts/ @home-assistant/core
|
||||
/homeassistant/components/tuya/ @Tuya @zlinoliver
|
||||
/tests/components/tuya/ @Tuya @zlinoliver
|
||||
/homeassistant/components/tuya/ @Tuya @zlinoliver @frenck
|
||||
/tests/components/tuya/ @Tuya @zlinoliver @frenck
|
||||
/homeassistant/components/twentemilieu/ @frenck
|
||||
/tests/components/twentemilieu/ @frenck
|
||||
/homeassistant/components/twinkly/ @dr1rrb @Robbie1221 @Olen
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"domain": "microsoft",
|
||||
"name": "Microsoft",
|
||||
"integrations": [
|
||||
"azure_data_explorer",
|
||||
"azure_devops",
|
||||
"azure_event_hub",
|
||||
"azure_service_bus",
|
||||
|
||||
@@ -34,17 +34,17 @@
|
||||
"services": {
|
||||
"capture_image": {
|
||||
"name": "Capture image",
|
||||
"description": "Requests a new image capture from a camera device.",
|
||||
"description": "Request a new image capture from a camera device.",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"name": "Entity",
|
||||
"description": "Entity ID of the camera to request an image from."
|
||||
"description": "Entity id of the camera to request an image."
|
||||
}
|
||||
}
|
||||
},
|
||||
"change_setting": {
|
||||
"name": "Change setting",
|
||||
"description": "Changes an Abode system setting.",
|
||||
"description": "Change an Abode system setting.",
|
||||
"fields": {
|
||||
"setting": {
|
||||
"name": "Setting",
|
||||
@@ -58,11 +58,11 @@
|
||||
},
|
||||
"trigger_automation": {
|
||||
"name": "Trigger automation",
|
||||
"description": "Triggers an Abode automation.",
|
||||
"description": "Trigger an Abode automation.",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"name": "Entity",
|
||||
"description": "Entity ID of the automation to trigger."
|
||||
"description": "Entity id of the automation to trigger."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,54 +39,45 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
key="temp",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"humidity": SensorEntityDescription(
|
||||
key="humidity",
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"pressure": SensorEntityDescription(
|
||||
key="pressure",
|
||||
device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE,
|
||||
native_unit_of_measurement=UnitOfPressure.MBAR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"battery": SensorEntityDescription(
|
||||
key="battery",
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"co2": SensorEntityDescription(
|
||||
key="co2",
|
||||
device_class=SensorDeviceClass.CO2,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"voc": SensorEntityDescription(
|
||||
key="voc",
|
||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"light": SensorEntityDescription(
|
||||
key="light",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
translation_key="light",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"virusRisk": SensorEntityDescription(
|
||||
key="virusRisk",
|
||||
translation_key="virus_risk",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"mold": SensorEntityDescription(
|
||||
key="mold",
|
||||
translation_key="mold",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"rssi": SensorEntityDescription(
|
||||
key="rssi",
|
||||
@@ -94,19 +85,16 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"pm1": SensorEntityDescription(
|
||||
key="pm1",
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
device_class=SensorDeviceClass.PM1,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"pm25": SensorEntityDescription(
|
||||
key="pm25",
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
device_class=SensorDeviceClass.PM25,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"invalid_unique_id": "Impossible to determine a valid unique ID for the device"
|
||||
"invalid_unique_id": "Impossible to determine a valid unique id for the device"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
@@ -38,17 +38,17 @@
|
||||
}
|
||||
},
|
||||
"apps": {
|
||||
"title": "Configure Android apps",
|
||||
"description": "Configure application ID {app_id}",
|
||||
"title": "Configure Android Apps",
|
||||
"description": "Configure application id {app_id}",
|
||||
"data": {
|
||||
"app_name": "Application name",
|
||||
"app_name": "Application Name",
|
||||
"app_id": "Application ID",
|
||||
"app_delete": "Check to delete this application"
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"title": "Configure Android state detection rules",
|
||||
"description": "Configure detection rule for application ID {rule_id}",
|
||||
"description": "Configure detection rule for application id {rule_id}",
|
||||
"data": {
|
||||
"rule_id": "[%key:component::androidtv::options::step::apps::data::app_id%]",
|
||||
"rule_values": "List of state detection rules (see documentation)",
|
||||
|
||||
@@ -29,8 +29,6 @@ class ApSystemsSensorData:
|
||||
class ApSystemsDataCoordinator(DataUpdateCoordinator[ApSystemsSensorData]):
|
||||
"""Coordinator used for all sensors."""
|
||||
|
||||
device_version: str
|
||||
|
||||
def __init__(self, hass: HomeAssistant, api: APsystemsEZ1M) -> None:
|
||||
"""Initialize my coordinator."""
|
||||
super().__init__(
|
||||
@@ -48,7 +46,6 @@ class ApSystemsDataCoordinator(DataUpdateCoordinator[ApSystemsSensorData]):
|
||||
raise UpdateFailed from None
|
||||
self.api.max_power = device_info.maxPower
|
||||
self.api.min_power = device_info.minPower
|
||||
self.device_version = device_info.devVer
|
||||
|
||||
async def _async_update_data(self) -> ApSystemsSensorData:
|
||||
try:
|
||||
|
||||
@@ -21,8 +21,7 @@ class ApSystemsEntity(Entity):
|
||||
"""Initialize the APsystems entity."""
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, data.device_id)},
|
||||
serial_number=data.device_id,
|
||||
manufacturer="APsystems",
|
||||
model="EZ1-M",
|
||||
serial_number=data.device_id,
|
||||
sw_version=data.coordinator.device_version.split(" ")[1],
|
||||
)
|
||||
|
||||
@@ -19,5 +19,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/aranet",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["aranet4==2.5.0"]
|
||||
"requirements": ["aranet4==2.4.0"]
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ from homeassistant.components.sensor import (
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_MANUFACTURER,
|
||||
ATTR_MODEL,
|
||||
ATTR_NAME,
|
||||
ATTR_SW_VERSION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
@@ -143,7 +142,6 @@ def _sensor_device_info_to_hass(
|
||||
if adv.readings and adv.readings.name:
|
||||
hass_device_info[ATTR_NAME] = adv.readings.name
|
||||
hass_device_info[ATTR_MANUFACTURER] = ARANET_MANUFACTURER_NAME
|
||||
hass_device_info[ATTR_MODEL] = adv.readings.type.model
|
||||
if adv.manufacturer_data:
|
||||
hass_device_info[ATTR_SW_VERSION] = str(adv.manufacturer_data.version)
|
||||
return hass_device_info
|
||||
|
||||
@@ -90,7 +90,7 @@ class ArubaDeviceScanner(DeviceScanner):
|
||||
"""Retrieve data from Aruba Access Point and return parsed result."""
|
||||
|
||||
connect = f"ssh {self.username}@{self.host} -o HostKeyAlgorithms=ssh-rsa"
|
||||
ssh: pexpect.spawn[str] = pexpect.spawn(connect, encoding="utf-8")
|
||||
ssh = pexpect.spawn(connect)
|
||||
query = ssh.expect(
|
||||
[
|
||||
"password:",
|
||||
@@ -125,12 +125,12 @@ class ArubaDeviceScanner(DeviceScanner):
|
||||
ssh.expect("#")
|
||||
ssh.sendline("show clients")
|
||||
ssh.expect("#")
|
||||
devices_result = (ssh.before or "").splitlines()
|
||||
devices_result = ssh.before.split(b"\r\n")
|
||||
ssh.sendline("exit")
|
||||
|
||||
devices: dict[str, dict[str, str]] = {}
|
||||
for device in devices_result:
|
||||
if match := _DEVICES_REGEX.search(device):
|
||||
if match := _DEVICES_REGEX.search(device.decode("utf-8")):
|
||||
devices[match.group("ip")] = {
|
||||
"ip": match.group("ip"),
|
||||
"mac": match.group("mac").upper(),
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pexpect", "ptyprocess"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["pexpect==4.9.0"]
|
||||
"requirements": ["pexpect==4.6.0"]
|
||||
}
|
||||
|
||||
@@ -108,7 +108,6 @@ async def async_pipeline_from_audio_stream(
|
||||
device_id: str | None = None,
|
||||
start_stage: PipelineStage = PipelineStage.STT,
|
||||
end_stage: PipelineStage = PipelineStage.TTS,
|
||||
conversation_extra_system_prompt: str | None = None,
|
||||
) -> None:
|
||||
"""Create an audio pipeline from an audio stream.
|
||||
|
||||
@@ -120,7 +119,6 @@ async def async_pipeline_from_audio_stream(
|
||||
stt_metadata=stt_metadata,
|
||||
stt_stream=stt_stream,
|
||||
wake_word_phrase=wake_word_phrase,
|
||||
conversation_extra_system_prompt=conversation_extra_system_prompt,
|
||||
run=PipelineRun(
|
||||
hass,
|
||||
context=context,
|
||||
|
||||
@@ -1010,11 +1010,7 @@ class PipelineRun:
|
||||
self.intent_agent = agent_info.id
|
||||
|
||||
async def recognize_intent(
|
||||
self,
|
||||
intent_input: str,
|
||||
conversation_id: str | None,
|
||||
device_id: str | None,
|
||||
conversation_extra_system_prompt: str | None,
|
||||
self, intent_input: str, conversation_id: str | None, device_id: str | None
|
||||
) -> str:
|
||||
"""Run intent recognition portion of pipeline. Returns text to speak."""
|
||||
if self.intent_agent is None:
|
||||
@@ -1049,7 +1045,6 @@ class PipelineRun:
|
||||
device_id=device_id,
|
||||
language=input_language,
|
||||
agent_id=self.intent_agent,
|
||||
extra_system_prompt=conversation_extra_system_prompt,
|
||||
)
|
||||
processed_locally = self.intent_agent == conversation.HOME_ASSISTANT_AGENT
|
||||
|
||||
@@ -1397,13 +1392,8 @@ class PipelineInput:
|
||||
"""Input for text-to-speech. Required when start_stage = tts."""
|
||||
|
||||
conversation_id: str | None = None
|
||||
"""Identifier for the conversation."""
|
||||
|
||||
conversation_extra_system_prompt: str | None = None
|
||||
"""Extra prompt information for the conversation agent."""
|
||||
|
||||
device_id: str | None = None
|
||||
"""Identifier of the device that is processing the input/output of the pipeline."""
|
||||
|
||||
async def execute(self) -> None:
|
||||
"""Run pipeline."""
|
||||
@@ -1493,7 +1483,6 @@ class PipelineInput:
|
||||
intent_input,
|
||||
self.conversation_id,
|
||||
self.device_id,
|
||||
self.conversation_extra_system_prompt,
|
||||
)
|
||||
if tts_input.strip():
|
||||
current_stage = PipelineStage.TTS
|
||||
|
||||
@@ -34,7 +34,7 @@ class BangOlufsenData:
|
||||
|
||||
type BangOlufsenConfigEntry = ConfigEntry[BangOlufsenData]
|
||||
|
||||
PLATFORMS = [Platform.EVENT, Platform.MEDIA_PLAYER]
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: BangOlufsenConfigEntry) -> bool:
|
||||
|
||||
@@ -79,7 +79,6 @@ class WebsocketNotification(StrEnum):
|
||||
"""Enum for WebSocket notification types."""
|
||||
|
||||
ACTIVE_LISTENING_MODE = "active_listening_mode"
|
||||
BUTTON = "button"
|
||||
PLAYBACK_ERROR = "playback_error"
|
||||
PLAYBACK_METADATA = "playback_metadata"
|
||||
PLAYBACK_PROGRESS = "playback_progress"
|
||||
@@ -204,60 +203,14 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray(
|
||||
),
|
||||
]
|
||||
)
|
||||
# Map for storing compatibility of devices.
|
||||
|
||||
MODEL_SUPPORT_DEVICE_BUTTONS: Final[str] = "device_buttons"
|
||||
|
||||
MODEL_SUPPORT_MAP = {
|
||||
MODEL_SUPPORT_DEVICE_BUTTONS: (
|
||||
BangOlufsenModel.BEOLAB_8,
|
||||
BangOlufsenModel.BEOLAB_28,
|
||||
BangOlufsenModel.BEOSOUND_2,
|
||||
BangOlufsenModel.BEOSOUND_A5,
|
||||
BangOlufsenModel.BEOSOUND_A9,
|
||||
BangOlufsenModel.BEOSOUND_BALANCE,
|
||||
BangOlufsenModel.BEOSOUND_EMERGE,
|
||||
BangOlufsenModel.BEOSOUND_LEVEL,
|
||||
BangOlufsenModel.BEOSOUND_THEATRE,
|
||||
)
|
||||
}
|
||||
|
||||
# Device events
|
||||
BANG_OLUFSEN_WEBSOCKET_EVENT: Final[str] = f"{DOMAIN}_websocket_event"
|
||||
|
||||
# Dict used to translate native Bang & Olufsen event names to string.json compatible ones
|
||||
EVENT_TRANSLATION_MAP: dict[str, str] = {
|
||||
"shortPress (Release)": "short_press_release",
|
||||
"longPress (Timeout)": "long_press_timeout",
|
||||
"longPress (Release)": "long_press_release",
|
||||
"veryLongPress (Timeout)": "very_long_press_timeout",
|
||||
"veryLongPress (Release)": "very_long_press_release",
|
||||
}
|
||||
|
||||
CONNECTION_STATUS: Final[str] = "CONNECTION_STATUS"
|
||||
|
||||
DEVICE_BUTTONS: Final[list[str]] = [
|
||||
"Bluetooth",
|
||||
"Microphone",
|
||||
"Next",
|
||||
"PlayPause",
|
||||
"Preset1",
|
||||
"Preset2",
|
||||
"Preset3",
|
||||
"Preset4",
|
||||
"Previous",
|
||||
"Volume",
|
||||
]
|
||||
|
||||
|
||||
DEVICE_BUTTON_EVENTS: Final[list[str]] = [
|
||||
"short_press_release",
|
||||
"long_press_timeout",
|
||||
"long_press_release",
|
||||
"very_long_press_timeout",
|
||||
"very_long_press_release",
|
||||
]
|
||||
|
||||
# Beolink Converter NL/ML sources need to be transformed to upper case
|
||||
BEOLINK_JOIN_SOURCES_TO_UPPER = (
|
||||
"aux_a",
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
"""Event entities for the Bang & Olufsen integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.event import EventDeviceClass, EventEntity
|
||||
from homeassistant.const import CONF_MODEL
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import BangOlufsenConfigEntry
|
||||
from .const import (
|
||||
CONNECTION_STATUS,
|
||||
DEVICE_BUTTON_EVENTS,
|
||||
DEVICE_BUTTONS,
|
||||
MODEL_SUPPORT_DEVICE_BUTTONS,
|
||||
MODEL_SUPPORT_MAP,
|
||||
WebsocketNotification,
|
||||
)
|
||||
from .entity import BangOlufsenEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BangOlufsenConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Sensor entities from config entry."""
|
||||
|
||||
if config_entry.data[CONF_MODEL] in MODEL_SUPPORT_MAP[MODEL_SUPPORT_DEVICE_BUTTONS]:
|
||||
async_add_entities(
|
||||
BangOlufsenButtonEvent(config_entry, button_type)
|
||||
for button_type in DEVICE_BUTTONS
|
||||
)
|
||||
|
||||
|
||||
class BangOlufsenButtonEvent(BangOlufsenEntity, EventEntity):
|
||||
"""Event class for Button events."""
|
||||
|
||||
_attr_device_class = EventDeviceClass.BUTTON
|
||||
_attr_entity_registry_enabled_default = False
|
||||
_attr_event_types = DEVICE_BUTTON_EVENTS
|
||||
|
||||
def __init__(self, config_entry: BangOlufsenConfigEntry, button_type: str) -> None:
|
||||
"""Initialize Button."""
|
||||
super().__init__(config_entry, config_entry.runtime_data.client)
|
||||
|
||||
self._attr_unique_id = f"{self._unique_id}_{button_type}"
|
||||
|
||||
# Make the native button name Home Assistant compatible
|
||||
self._attr_translation_key = button_type.lower()
|
||||
|
||||
self._button_type = button_type
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Listen to WebSocket button events."""
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
f"{self._unique_id}_{CONNECTION_STATUS}",
|
||||
self._async_update_connection_state,
|
||||
)
|
||||
)
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
f"{self._unique_id}_{WebsocketNotification.BUTTON}_{self._button_type}",
|
||||
self._async_handle_event,
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_handle_event(self, event: str) -> None:
|
||||
"""Handle event."""
|
||||
self._trigger_event(event)
|
||||
self.async_write_ha_state()
|
||||
@@ -1,12 +1,7 @@
|
||||
{
|
||||
"common": {
|
||||
"jid_options_description": "Advanced grouping options, where devices' unique Beolink IDs (Called JIDs) are used directly. JIDs can be found in the state attributes of the media player entity.",
|
||||
"jid_options_name": "JID options",
|
||||
"long_press_release": "Release of long press",
|
||||
"long_press_timeout": "Long press",
|
||||
"short_press_release": "Release of short press",
|
||||
"very_long_press_release": "Release of very long press",
|
||||
"very_long_press_timeout": "Very long press"
|
||||
"jid_options_description": "Advanced grouping options, where devices' unique Beolink IDs (Called JIDs) are used directly. JIDs can be found in the state attributes of the media player entity."
|
||||
},
|
||||
"config": {
|
||||
"error": {
|
||||
@@ -34,150 +29,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"event": {
|
||||
"bluetooth": {
|
||||
"name": "Bluetooth",
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
"short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]",
|
||||
"long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]",
|
||||
"long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]",
|
||||
"very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]",
|
||||
"very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"microphone": {
|
||||
"name": "Microphone",
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
"short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]",
|
||||
"long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]",
|
||||
"long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]",
|
||||
"very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]",
|
||||
"very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"next": {
|
||||
"name": "Next",
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
"short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]",
|
||||
"long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]",
|
||||
"long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]",
|
||||
"very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]",
|
||||
"very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"playpause": {
|
||||
"name": "Play / Pause",
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
"short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]",
|
||||
"long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]",
|
||||
"long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]",
|
||||
"very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]",
|
||||
"very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"preset1": {
|
||||
"name": "Favourite 1",
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
"short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]",
|
||||
"long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]",
|
||||
"long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]",
|
||||
"very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]",
|
||||
"very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"preset2": {
|
||||
"name": "Favourite 2",
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
"short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]",
|
||||
"long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]",
|
||||
"long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]",
|
||||
"very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]",
|
||||
"very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"preset3": {
|
||||
"name": "Favourite 3",
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
"short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]",
|
||||
"long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]",
|
||||
"long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]",
|
||||
"very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]",
|
||||
"very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"preset4": {
|
||||
"name": "Favourite 4",
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
"short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]",
|
||||
"long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]",
|
||||
"long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]",
|
||||
"very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]",
|
||||
"very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"previous": {
|
||||
"name": "Previous",
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
"short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]",
|
||||
"long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]",
|
||||
"long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]",
|
||||
"very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]",
|
||||
"very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"volume": {
|
||||
"name": "Volume",
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
"short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]",
|
||||
"long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]",
|
||||
"long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]",
|
||||
"very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]",
|
||||
"very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"source_ids": {
|
||||
"options": {
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from mozart_api.models import (
|
||||
ButtonEvent,
|
||||
ListeningModeProps,
|
||||
PlaybackContentMetadata,
|
||||
PlaybackError,
|
||||
@@ -28,7 +26,6 @@ from homeassistant.util.enum import try_parse_enum
|
||||
from .const import (
|
||||
BANG_OLUFSEN_WEBSOCKET_EVENT,
|
||||
CONNECTION_STATUS,
|
||||
EVENT_TRANSLATION_MAP,
|
||||
WebsocketNotification,
|
||||
)
|
||||
from .entity import BangOlufsenBase
|
||||
@@ -57,8 +54,6 @@ class BangOlufsenWebsocket(BangOlufsenBase):
|
||||
self._client.get_active_listening_mode_notifications(
|
||||
self.on_active_listening_mode
|
||||
)
|
||||
self._client.get_button_notifications(self.on_button_notification)
|
||||
|
||||
self._client.get_playback_error_notifications(
|
||||
self.on_playback_error_notification
|
||||
)
|
||||
@@ -109,19 +104,6 @@ class BangOlufsenWebsocket(BangOlufsenBase):
|
||||
notification,
|
||||
)
|
||||
|
||||
def on_button_notification(self, notification: ButtonEvent) -> None:
|
||||
"""Send button dispatch."""
|
||||
# State is expected to always be available.
|
||||
if TYPE_CHECKING:
|
||||
assert notification.state
|
||||
|
||||
# Send to event entity
|
||||
async_dispatcher_send(
|
||||
self.hass,
|
||||
f"{self._unique_id}_{WebsocketNotification.BUTTON}_{notification.button}",
|
||||
EVENT_TRANSLATION_MAP[notification.state],
|
||||
)
|
||||
|
||||
def on_notification_notification(
|
||||
self, notification: WebsocketNotificationTag
|
||||
) -> None:
|
||||
|
||||
@@ -71,6 +71,27 @@ class BluesoundConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
),
|
||||
)
|
||||
|
||||
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Import bluesound config entry from configuration.yaml."""
|
||||
session = async_get_clientsession(self.hass)
|
||||
async with Player(
|
||||
import_data[CONF_HOST], import_data[CONF_PORT], session=session
|
||||
) as player:
|
||||
try:
|
||||
sync_status = await player.sync_status(timeout=1)
|
||||
except PlayerUnreachableError:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
await self.async_set_unique_id(
|
||||
format_unique_id(sync_status.mac, import_data[CONF_PORT])
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(
|
||||
title=sync_status.name,
|
||||
data=import_data,
|
||||
)
|
||||
|
||||
async def async_step_zeroconf(
|
||||
self, discovery_info: zeroconf.ZeroconfServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
|
||||
@@ -15,6 +15,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components import media_source
|
||||
from homeassistant.components.media_player import (
|
||||
PLATFORM_SCHEMA as MEDIA_PLAYER_PLATFORM_SCHEMA,
|
||||
BrowseMedia,
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerEntityFeature,
|
||||
@@ -22,10 +23,16 @@ from homeassistant.components.media_player import (
|
||||
MediaType,
|
||||
async_process_play_media_url,
|
||||
)
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.config_entries import SOURCE_IMPORT
|
||||
from homeassistant.const import CONF_HOST, CONF_HOSTS, CONF_NAME, CONF_PORT
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
entity_platform,
|
||||
issue_registry as ir,
|
||||
)
|
||||
from homeassistant.helpers.device_registry import (
|
||||
CONNECTION_NETWORK_MAC,
|
||||
DeviceInfo,
|
||||
@@ -36,9 +43,10 @@ from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_send,
|
||||
)
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from .const import ATTR_BLUESOUND_GROUP, ATTR_MASTER, DOMAIN
|
||||
from .const import ATTR_BLUESOUND_GROUP, ATTR_MASTER, DOMAIN, INTEGRATION_TITLE
|
||||
from .utils import dispatcher_join_signal, dispatcher_unjoin_signal, format_unique_id
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -63,6 +71,64 @@ SYNC_STATUS_INTERVAL = timedelta(minutes=5)
|
||||
|
||||
POLL_TIMEOUT = 120
|
||||
|
||||
PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(CONF_HOSTS): vol.All(
|
||||
cv.ensure_list,
|
||||
[
|
||||
{
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
||||
}
|
||||
],
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def _async_import(hass: HomeAssistant, config: ConfigType) -> None:
|
||||
"""Import config entry from configuration.yaml."""
|
||||
if not hass.config_entries.async_entries(DOMAIN):
|
||||
# Start import flow
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_IMPORT}, data=config
|
||||
)
|
||||
if (
|
||||
result["type"] == FlowResultType.ABORT
|
||||
and result["reason"] == "cannot_connect"
|
||||
):
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"deprecated_yaml_import_issue_{result['reason']}",
|
||||
breaks_in_ha_version="2025.2.0",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key=f"deprecated_yaml_import_issue_{result['reason']}",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"integration_title": INTEGRATION_TITLE,
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
HOMEASSISTANT_DOMAIN,
|
||||
f"deprecated_yaml_{DOMAIN}",
|
||||
breaks_in_ha_version="2025.2.0",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_yaml",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"integration_title": INTEGRATION_TITLE,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -93,6 +159,22 @@ async def async_setup_entry(
|
||||
async_add_entities([bluesound_player], update_before_add=True)
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None,
|
||||
) -> None:
|
||||
"""Trigger import flows."""
|
||||
hosts = config.get(CONF_HOSTS, [])
|
||||
for host in hosts:
|
||||
import_data = {
|
||||
CONF_HOST: host[CONF_HOST],
|
||||
CONF_PORT: host.get(CONF_PORT, 11000),
|
||||
}
|
||||
hass.async_create_task(_async_import(hass, import_data))
|
||||
|
||||
|
||||
class BluesoundPlayer(MediaPlayerEntity):
|
||||
"""Representation of a Bluesound Player."""
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import configparser
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import aiohttp
|
||||
@@ -129,7 +129,7 @@ class ChromecastInfo:
|
||||
class ChromeCastZeroconf:
|
||||
"""Class to hold a zeroconf instance."""
|
||||
|
||||
__zconf: ClassVar[zeroconf.HaZeroconf | None] = None
|
||||
__zconf: zeroconf.HaZeroconf | None = None
|
||||
|
||||
@classmethod
|
||||
def set_zeroconf(cls, zconf: zeroconf.HaZeroconf) -> None:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from pexpect import pxssh
|
||||
import voluptuous as vol
|
||||
@@ -100,11 +101,11 @@ class CiscoDeviceScanner(DeviceScanner):
|
||||
|
||||
return False
|
||||
|
||||
def _get_arp_data(self) -> str | None:
|
||||
def _get_arp_data(self):
|
||||
"""Open connection to the router and get arp entries."""
|
||||
|
||||
try:
|
||||
cisco_ssh: pxssh.pxssh[str] = pxssh.pxssh(encoding="uft-8")
|
||||
cisco_ssh = pxssh.pxssh()
|
||||
cisco_ssh.login(
|
||||
self.host,
|
||||
self.username,
|
||||
@@ -114,11 +115,12 @@ class CiscoDeviceScanner(DeviceScanner):
|
||||
)
|
||||
|
||||
# Find the hostname
|
||||
initial_line = (cisco_ssh.before or "").splitlines()
|
||||
initial_line = cisco_ssh.before.decode("utf-8").splitlines()
|
||||
router_hostname = initial_line[len(initial_line) - 1]
|
||||
router_hostname += "#"
|
||||
# Set the discovered hostname as prompt
|
||||
cisco_ssh.PROMPT = f"(?i)^{router_hostname}"
|
||||
regex_expression = f"(?i)^{router_hostname}".encode()
|
||||
cisco_ssh.PROMPT = re.compile(regex_expression, re.MULTILINE)
|
||||
# Allow full arp table to print at once
|
||||
cisco_ssh.sendline("terminal length 0")
|
||||
cisco_ssh.prompt(1)
|
||||
@@ -126,11 +128,13 @@ class CiscoDeviceScanner(DeviceScanner):
|
||||
cisco_ssh.sendline("show ip arp")
|
||||
cisco_ssh.prompt(1)
|
||||
|
||||
devices_result = cisco_ssh.before
|
||||
|
||||
return devices_result.decode("utf-8")
|
||||
except pxssh.ExceptionPxssh as px_e:
|
||||
_LOGGER.error("Failed to login via pxssh: %s", px_e)
|
||||
return None
|
||||
|
||||
return cisco_ssh.before
|
||||
return None
|
||||
|
||||
|
||||
def _parse_cisco_mac_address(cisco_hardware_addr):
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pexpect", "ptyprocess"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["pexpect==4.9.0"]
|
||||
"requirements": ["pexpect==4.6.0"]
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ import base64
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine, Mapping
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, Self
|
||||
|
||||
from aiohttp import ClientError, ClientTimeout
|
||||
from aiohttp import ClientError, ClientTimeout, StreamReader
|
||||
from hass_nabucasa import Cloud, CloudError
|
||||
from hass_nabucasa.cloud_api import (
|
||||
async_files_delete_file,
|
||||
@@ -19,7 +19,6 @@ from hass_nabucasa.cloud_api import (
|
||||
|
||||
from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
|
||||
from .client import CloudClient
|
||||
@@ -74,6 +73,31 @@ def async_register_backup_agents_listener(
|
||||
return unsub
|
||||
|
||||
|
||||
class ChunkAsyncStreamIterator:
|
||||
"""Async iterator for chunked streams.
|
||||
|
||||
Based on aiohttp.streams.ChunkTupleAsyncStreamIterator, but yields
|
||||
bytes instead of tuple[bytes, bool].
|
||||
"""
|
||||
|
||||
__slots__ = ("_stream",)
|
||||
|
||||
def __init__(self, stream: StreamReader) -> None:
|
||||
"""Initialize."""
|
||||
self._stream = stream
|
||||
|
||||
def __aiter__(self) -> Self:
|
||||
"""Iterate."""
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> bytes:
|
||||
"""Yield next chunk."""
|
||||
rv = await self._stream.readchunk()
|
||||
if rv == (b"", False):
|
||||
raise StopAsyncIteration
|
||||
return rv[0]
|
||||
|
||||
|
||||
class CloudBackupAgent(BackupAgent):
|
||||
"""Cloud backup agent."""
|
||||
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/compensation",
|
||||
"iot_class": "calculated",
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["numpy==2.2.1"]
|
||||
"requirements": ["numpy==2.2.0"]
|
||||
}
|
||||
|
||||
@@ -46,13 +46,6 @@ def async_setup(hass: HomeAssistant) -> bool:
|
||||
hass.http.register_view(OptionManagerFlowIndexView(hass.config_entries.options))
|
||||
hass.http.register_view(OptionManagerFlowResourceView(hass.config_entries.options))
|
||||
|
||||
hass.http.register_view(
|
||||
SubentryManagerFlowIndexView(hass.config_entries.subentries)
|
||||
)
|
||||
hass.http.register_view(
|
||||
SubentryManagerFlowResourceView(hass.config_entries.subentries)
|
||||
)
|
||||
|
||||
websocket_api.async_register_command(hass, config_entries_get)
|
||||
websocket_api.async_register_command(hass, config_entry_disable)
|
||||
websocket_api.async_register_command(hass, config_entry_get_single)
|
||||
@@ -61,9 +54,6 @@ def async_setup(hass: HomeAssistant) -> bool:
|
||||
websocket_api.async_register_command(hass, config_entries_progress)
|
||||
websocket_api.async_register_command(hass, ignore_config_flow)
|
||||
|
||||
websocket_api.async_register_command(hass, config_subentry_delete)
|
||||
websocket_api.async_register_command(hass, config_subentry_list)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -295,66 +285,6 @@ class OptionManagerFlowResourceView(
|
||||
return await super().post(request, flow_id)
|
||||
|
||||
|
||||
class SubentryManagerFlowIndexView(
|
||||
FlowManagerIndexView[config_entries.ConfigSubentryFlowManager]
|
||||
):
|
||||
"""View to create subentry flows."""
|
||||
|
||||
url = "/api/config/config_entries/subentries/flow"
|
||||
name = "api:config:config_entries:subentries:flow"
|
||||
|
||||
@require_admin(
|
||||
error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT)
|
||||
)
|
||||
@RequestDataValidator(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("handler"): vol.All(vol.Coerce(tuple), (str, str)),
|
||||
vol.Optional("show_advanced_options", default=False): cv.boolean,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
)
|
||||
async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response:
|
||||
"""Handle a POST request.
|
||||
|
||||
handler in request is [entry_id, subentry_type].
|
||||
"""
|
||||
return await super()._post_impl(request, data)
|
||||
|
||||
def get_context(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return context."""
|
||||
context = super().get_context(data)
|
||||
context["source"] = config_entries.SOURCE_USER
|
||||
if subentry_id := data.get("subentry_id"):
|
||||
context["source"] = config_entries.SOURCE_RECONFIGURE
|
||||
context["subentry_id"] = subentry_id
|
||||
return context
|
||||
|
||||
|
||||
class SubentryManagerFlowResourceView(
|
||||
FlowManagerResourceView[config_entries.ConfigSubentryFlowManager]
|
||||
):
|
||||
"""View to interact with the subentry flow manager."""
|
||||
|
||||
url = "/api/config/config_entries/subentries/flow/{flow_id}"
|
||||
name = "api:config:config_entries:subentries:flow:resource"
|
||||
|
||||
@require_admin(
|
||||
error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT)
|
||||
)
|
||||
async def get(self, request: web.Request, /, flow_id: str) -> web.Response:
|
||||
"""Get the current state of a data_entry_flow."""
|
||||
return await super().get(request, flow_id)
|
||||
|
||||
@require_admin(
|
||||
error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT)
|
||||
)
|
||||
async def post(self, request: web.Request, flow_id: str) -> web.Response:
|
||||
"""Handle a POST request."""
|
||||
return await super().post(request, flow_id)
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command({"type": "config_entries/flow/progress"})
|
||||
def config_entries_progress(
|
||||
@@ -658,63 +588,3 @@ async def _async_matching_config_entries_json_fragments(
|
||||
)
|
||||
or (filter_is_not_helper and entry.domain not in integrations)
|
||||
]
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
"type": "config_entries/subentries/list",
|
||||
"entry_id": str,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def config_subentry_list(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""List subentries of a config entry."""
|
||||
entry = get_entry(hass, connection, msg["entry_id"], msg["id"])
|
||||
if entry is None:
|
||||
return
|
||||
|
||||
result = [
|
||||
{
|
||||
"subentry_id": subentry.subentry_id,
|
||||
"subentry_type": subentry.subentry_type,
|
||||
"title": subentry.title,
|
||||
"unique_id": subentry.unique_id,
|
||||
}
|
||||
for subentry in entry.subentries.values()
|
||||
]
|
||||
connection.send_result(msg["id"], result)
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
"type": "config_entries/subentries/delete",
|
||||
"entry_id": str,
|
||||
"subentry_id": str,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def config_subentry_delete(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Delete a subentry of a config entry."""
|
||||
entry = get_entry(hass, connection, msg["entry_id"], msg["id"])
|
||||
if entry is None:
|
||||
return
|
||||
|
||||
try:
|
||||
hass.config_entries.async_remove_subentry(entry, msg["subentry_id"])
|
||||
except config_entries.UnknownSubEntry:
|
||||
connection.send_error(
|
||||
msg["id"], websocket_api.const.ERR_NOT_FOUND, "Config subentry not found"
|
||||
)
|
||||
return
|
||||
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
@@ -75,7 +75,6 @@ async def async_converse(
|
||||
language: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
device_id: str | None = None,
|
||||
extra_system_prompt: str | None = None,
|
||||
) -> ConversationResult:
|
||||
"""Process text and get intent."""
|
||||
agent = async_get_agent(hass, agent_id)
|
||||
@@ -100,7 +99,6 @@ async def async_converse(
|
||||
device_id=device_id,
|
||||
language=language,
|
||||
agent_id=agent_id,
|
||||
extra_system_prompt=extra_system_prompt,
|
||||
)
|
||||
with async_conversation_trace() as trace:
|
||||
trace.add_event(
|
||||
|
||||
@@ -40,9 +40,6 @@ class ConversationInput:
|
||||
agent_id: str | None = None
|
||||
"""Agent to use for processing."""
|
||||
|
||||
extra_system_prompt: str | None = None
|
||||
"""Extra prompt to provide extra info to LLMs how to understand the command."""
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return input as a dict."""
|
||||
return {
|
||||
@@ -52,7 +49,6 @@ class ConversationInput:
|
||||
"device_id": self.device_id,
|
||||
"language": self.language,
|
||||
"agent_id": self.agent_id,
|
||||
"extra_system_prompt": self.extra_system_prompt,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.TODO]
|
||||
PLATFORMS: list[Platform] = [Platform.TODO]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: CookidooConfigEntry) -> bool:
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
"""Support for Cookidoo buttons."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from cookidoo_api import Cookidoo, CookidooException
|
||||
|
||||
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator
|
||||
from .entity import CookidooBaseEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class CookidooButtonEntityDescription(ButtonEntityDescription):
|
||||
"""Describes cookidoo button entity."""
|
||||
|
||||
press_fn: Callable[[Cookidoo], Awaitable[None]]
|
||||
|
||||
|
||||
TODO_CLEAR = CookidooButtonEntityDescription(
|
||||
key="todo_clear",
|
||||
translation_key="todo_clear",
|
||||
press_fn=lambda client: client.clear_shopping_list(),
|
||||
entity_registry_enabled_default=False,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: CookidooConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Cookidoo button entities based on a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities([CookidooButton(coordinator, TODO_CLEAR)])
|
||||
|
||||
|
||||
class CookidooButton(CookidooBaseEntity, ButtonEntity):
|
||||
"""Defines an Cookidoo button."""
|
||||
|
||||
entity_description: CookidooButtonEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: CookidooDataUpdateCoordinator,
|
||||
description: CookidooButtonEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize cookidoo button."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}"
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Press the button."""
|
||||
try:
|
||||
await self.entity_description.press_fn(self.coordinator.cookidoo)
|
||||
except CookidooException as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="button_clear_todo_failed",
|
||||
) from e
|
||||
await self.coordinator.async_refresh()
|
||||
@@ -1,10 +1,5 @@
|
||||
{
|
||||
"entity": {
|
||||
"button": {
|
||||
"todo_clear": {
|
||||
"default": "mdi:cart-off"
|
||||
}
|
||||
},
|
||||
"todo": {
|
||||
"ingredient_list": {
|
||||
"default": "mdi:cart-plus"
|
||||
|
||||
@@ -48,11 +48,6 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"button": {
|
||||
"todo_clear": {
|
||||
"name": "Clear shopping list and additional purchases"
|
||||
}
|
||||
},
|
||||
"todo": {
|
||||
"ingredient_list": {
|
||||
"name": "Shopping list"
|
||||
@@ -63,9 +58,6 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"button_clear_todo_failed": {
|
||||
"message": "Failed to clear all items from the Cookidoo shopping list"
|
||||
},
|
||||
"todo_save_item_failed": {
|
||||
"message": "Failed to save {name} to Cookidoo shopping list"
|
||||
},
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
import os
|
||||
|
||||
from serial.tools.list_ports_common import ListPortInfo
|
||||
@@ -13,7 +12,7 @@ from .const import DONT_USE_USB, MANUAL_PATH, REFRESH_LIST
|
||||
|
||||
|
||||
def list_ports_as_str(
|
||||
serial_ports: Sequence[ListPortInfo], no_usb_option: bool = True
|
||||
serial_ports: list[ListPortInfo], no_usb_option: bool = True
|
||||
) -> list[str]:
|
||||
"""Represent currently available serial ports as string.
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Virtual integration: Decorquip."""
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"domain": "decorquip",
|
||||
"name": "Decorquip Dream",
|
||||
"integration_type": "virtual",
|
||||
"supported_by": "motion_blinds"
|
||||
}
|
||||
@@ -50,7 +50,7 @@
|
||||
"services": {
|
||||
"get_command": {
|
||||
"name": "Get command",
|
||||
"description": "Sends a generic HTTP get command.",
|
||||
"description": "Send sa generic HTTP get command.",
|
||||
"fields": {
|
||||
"command": {
|
||||
"name": "Command",
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pydoods"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["pydoods==1.0.2", "Pillow==11.1.0"]
|
||||
"requirements": ["pydoods==1.0.2", "Pillow==11.0.0"]
|
||||
}
|
||||
|
||||
@@ -57,11 +57,11 @@
|
||||
"services": {
|
||||
"get_gas_prices": {
|
||||
"name": "Get gas prices",
|
||||
"description": "Requests gas prices from easyEnergy.",
|
||||
"description": "Request gas prices from easyEnergy.",
|
||||
"fields": {
|
||||
"config_entry": {
|
||||
"name": "Config Entry",
|
||||
"description": "The configuration entry to use for this action."
|
||||
"description": "The config entry to use for this service."
|
||||
},
|
||||
"incl_vat": {
|
||||
"name": "VAT Included",
|
||||
@@ -79,7 +79,7 @@
|
||||
},
|
||||
"get_energy_usage_prices": {
|
||||
"name": "Get energy usage prices",
|
||||
"description": "Requests usage energy prices from easyEnergy.",
|
||||
"description": "Request usage energy prices from easyEnergy.",
|
||||
"fields": {
|
||||
"config_entry": {
|
||||
"name": "[%key:component::easyenergy::services::get_gas_prices::fields::config_entry::name%]",
|
||||
@@ -101,7 +101,7 @@
|
||||
},
|
||||
"get_energy_return_prices": {
|
||||
"name": "Get energy return prices",
|
||||
"description": "Requests return energy prices from easyEnergy.",
|
||||
"description": "Request return energy prices from easyEnergy.",
|
||||
"fields": {
|
||||
"config_entry": {
|
||||
"name": "[%key:component::easyenergy::services::get_gas_prices::fields::config_entry::name%]",
|
||||
|
||||
@@ -163,6 +163,11 @@ class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity):
|
||||
data: dict[str, Any] = {}
|
||||
data[ATTR_ERROR] = self.error
|
||||
|
||||
# these attributes are deprecated and can be removed in 2025.2
|
||||
for key, val in self.device.components.items():
|
||||
attr_name = ATTR_COMPONENT_PREFIX + key
|
||||
data[attr_name] = int(val * 100)
|
||||
|
||||
return data
|
||||
|
||||
def return_to_base(self, **kwargs: Any) -> None:
|
||||
|
||||
@@ -13,7 +13,7 @@ rules:
|
||||
docs-actions: done
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
docs-removal-instructions: todo
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: >
|
||||
|
||||
@@ -49,7 +49,7 @@ class ElkBinarySensor(ElkAttachedEntity, BinarySensorEntity):
|
||||
_element: Zone
|
||||
_attr_entity_registry_enabled_default = False
|
||||
|
||||
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
|
||||
def _element_changed(self, _: Element, changeset: Any) -> None:
|
||||
# Zone in NORMAL state is OFF; any other state is ON
|
||||
self._attr_is_on = bool(
|
||||
self._element.logical_status != ZoneLogicalStatus.NORMAL
|
||||
|
||||
@@ -120,7 +120,7 @@ class ElkCounter(ElkSensor):
|
||||
_attr_icon = "mdi:numeric"
|
||||
_element: Counter
|
||||
|
||||
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
|
||||
def _element_changed(self, _: Element, changeset: Any) -> None:
|
||||
self._attr_native_value = self._element.value
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ class ElkKeypad(ElkSensor):
|
||||
attrs["last_keypress"] = self._element.last_keypress
|
||||
return attrs
|
||||
|
||||
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
|
||||
def _element_changed(self, _: Element, changeset: Any) -> None:
|
||||
self._attr_native_value = temperature_to_state(
|
||||
self._element.temperature, UNDEFINED_TEMPERATURE
|
||||
)
|
||||
@@ -173,7 +173,7 @@ class ElkPanel(ElkSensor):
|
||||
attrs["system_trouble_status"] = self._element.system_trouble_status
|
||||
return attrs
|
||||
|
||||
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
|
||||
def _element_changed(self, _: Element, changeset: Any) -> None:
|
||||
if self._elk.is_connected():
|
||||
self._attr_native_value = (
|
||||
"Paused" if self._element.remote_programming_status else "Connected"
|
||||
@@ -188,7 +188,7 @@ class ElkSetting(ElkSensor):
|
||||
_attr_translation_key = "setting"
|
||||
_element: Setting
|
||||
|
||||
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
|
||||
def _element_changed(self, _: Element, changeset: Any) -> None:
|
||||
self._attr_native_value = self._element.value
|
||||
|
||||
@property
|
||||
@@ -257,7 +257,7 @@ class ElkZone(ElkSensor):
|
||||
return UnitOfElectricPotential.VOLT
|
||||
return None
|
||||
|
||||
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
|
||||
def _element_changed(self, _: Element, changeset: Any) -> None:
|
||||
if self._element.definition == ZoneType.TEMPERATURE:
|
||||
self._attr_native_value = temperature_to_state(
|
||||
self._element.temperature, UNDEFINED_TEMPERATURE
|
||||
|
||||
@@ -11,7 +11,7 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries
|
||||
from homeassistant.helpers.entity_registry import async_migrate_entries
|
||||
|
||||
from .config_flow import DEFAULT_RTSP_PORT
|
||||
from .const import CONF_RTSP_PORT, DOMAIN, LOGGER, SERVICE_PTZ, SERVICE_PTZ_PRESET
|
||||
@@ -36,9 +36,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||
|
||||
# Migrate to correct unique IDs for switches
|
||||
await async_migrate_entities(hass, entry)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
@@ -95,24 +92,3 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
LOGGER.debug("Migration to version %s successful", entry.version)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_migrate_entities(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Migrate old entry."""
|
||||
|
||||
@callback
|
||||
def _update_unique_id(
|
||||
entity_entry: RegistryEntry,
|
||||
) -> dict[str, str] | None:
|
||||
"""Update unique ID of entity entry."""
|
||||
if (
|
||||
entity_entry.domain == Platform.SWITCH
|
||||
and entity_entry.unique_id == "sleep_switch"
|
||||
):
|
||||
entity_new_unique_id = f"{entity_entry.config_entry_id}_sleep_switch"
|
||||
return {"new_unique_id": entity_new_unique_id}
|
||||
|
||||
return None
|
||||
|
||||
# Migrate entities
|
||||
await async_migrate_entries(hass, entry.entry_id, _update_unique_id)
|
||||
|
||||
@@ -41,7 +41,7 @@ class FoscamSleepSwitch(FoscamEntity, SwitchEntity):
|
||||
"""Initialize a Foscam Sleep Switch."""
|
||||
super().__init__(coordinator, config_entry.entry_id)
|
||||
|
||||
self._attr_unique_id = f"{config_entry.entry_id}_sleep_switch"
|
||||
self._attr_unique_id = "sleep_switch"
|
||||
self._attr_translation_key = "sleep_switch"
|
||||
self._attr_has_entity_name = True
|
||||
|
||||
|
||||
@@ -171,8 +171,6 @@ async def async_test_still(
|
||||
"""Verify that the still image is valid before we create an entity."""
|
||||
fmt = None
|
||||
if not (url := info.get(CONF_STILL_IMAGE_URL)):
|
||||
# If user didn't specify a still image URL,the automatically generated
|
||||
# still image that stream generates is always jpeg.
|
||||
return {}, info.get(CONF_CONTENT_TYPE, "image/jpeg")
|
||||
try:
|
||||
if not isinstance(url, template_helper.Template):
|
||||
@@ -257,6 +255,10 @@ async def async_test_and_preview_stream(
|
||||
"""
|
||||
if not (stream_source := info.get(CONF_STREAM_SOURCE)):
|
||||
return None
|
||||
# Import from stream.worker as stream cannot reexport from worker
|
||||
# without forcing the av dependency on default_config
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from homeassistant.components.stream.worker import StreamWorkerError
|
||||
|
||||
if not isinstance(stream_source, template_helper.Template):
|
||||
stream_source = template_helper.Template(stream_source, hass)
|
||||
@@ -292,6 +294,8 @@ async def async_test_and_preview_stream(
|
||||
f"{DOMAIN}.test_stream",
|
||||
)
|
||||
hls_provider = stream.add_provider(HLS_PROVIDER)
|
||||
except StreamWorkerError as err:
|
||||
raise InvalidStreamException("unknown_with_details", str(err)) from err
|
||||
except PermissionError as err:
|
||||
raise InvalidStreamException("stream_not_permitted") from err
|
||||
except OSError as err:
|
||||
@@ -311,8 +315,8 @@ async def async_test_and_preview_stream(
|
||||
return stream
|
||||
|
||||
|
||||
def register_still_preview(hass: HomeAssistant) -> None:
|
||||
"""Set up still image preview for camera feeds during config flow."""
|
||||
def register_preview(hass: HomeAssistant) -> None:
|
||||
"""Set up previews for camera feeds during config flow."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
if not hass.data[DOMAIN].get(IMAGE_PREVIEWS_ACTIVE):
|
||||
@@ -328,7 +332,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize Generic ConfigFlow."""
|
||||
self.preview_image_settings: dict[str, Any] = {}
|
||||
self.preview_cam: dict[str, Any] = {}
|
||||
self.preview_stream: Stream | None = None
|
||||
self.user_input: dict[str, Any] = {}
|
||||
self.title = ""
|
||||
@@ -368,10 +372,15 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
name = (
|
||||
slug(hass, still_url) or slug(hass, stream_url) or DEFAULT_NAME
|
||||
)
|
||||
if still_url is None:
|
||||
# If user didn't specify a still image URL,
|
||||
# The automatically generated still image that stream generates
|
||||
# is always jpeg
|
||||
user_input[CONF_CONTENT_TYPE] = "image/jpeg"
|
||||
self.user_input = user_input
|
||||
self.title = name
|
||||
# temporary preview for user to check the image
|
||||
self.preview_image_settings = user_input
|
||||
self.preview_cam = user_input
|
||||
return await self.async_step_user_confirm()
|
||||
elif self.user_input:
|
||||
user_input = self.user_input
|
||||
@@ -396,7 +405,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_create_entry(
|
||||
title=self.title, data={}, options=self.user_input
|
||||
)
|
||||
register_still_preview(self.hass)
|
||||
register_preview(self.hass)
|
||||
return self.async_show_form(
|
||||
step_id="user_confirm",
|
||||
data_schema=vol.Schema(
|
||||
@@ -419,7 +428,7 @@ class GenericOptionsFlowHandler(OptionsFlow):
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize Generic IP Camera options flow."""
|
||||
self.preview_image_settings: dict[str, Any] = {}
|
||||
self.preview_cam: dict[str, Any] = {}
|
||||
self.preview_stream: Stream | None = None
|
||||
self.user_input: dict[str, Any] = {}
|
||||
|
||||
@@ -446,6 +455,13 @@ class GenericOptionsFlowHandler(OptionsFlow):
|
||||
errors[CONF_STREAM_SOURCE] = str(err)
|
||||
self.preview_stream = None
|
||||
if not errors:
|
||||
user_input[CONF_CONTENT_TYPE] = still_format
|
||||
still_url = user_input.get(CONF_STILL_IMAGE_URL)
|
||||
if still_url is None:
|
||||
# If user didn't specify a still image URL,
|
||||
# The automatically generated still image that stream generates
|
||||
# is always jpeg
|
||||
still_format = "image/jpeg"
|
||||
data = {
|
||||
CONF_USE_WALLCLOCK_AS_TIMESTAMPS: self.config_entry.options.get(
|
||||
CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False
|
||||
@@ -456,7 +472,7 @@ class GenericOptionsFlowHandler(OptionsFlow):
|
||||
}
|
||||
self.user_input = data
|
||||
# temporary preview for user to check the image
|
||||
self.preview_image_settings = data
|
||||
self.preview_cam = data
|
||||
return await self.async_step_user_confirm()
|
||||
elif self.user_input:
|
||||
user_input = self.user_input
|
||||
@@ -484,7 +500,7 @@ class GenericOptionsFlowHandler(OptionsFlow):
|
||||
title=self.config_entry.title,
|
||||
data=self.user_input,
|
||||
)
|
||||
register_still_preview(self.hass)
|
||||
register_preview(self.hass)
|
||||
return self.async_show_form(
|
||||
step_id="user_confirm",
|
||||
data_schema=vol.Schema(
|
||||
@@ -526,7 +542,7 @@ class CameraImagePreview(HomeAssistantView):
|
||||
if not flow:
|
||||
_LOGGER.warning("Unknown flow while getting image preview")
|
||||
raise web.HTTPNotFound
|
||||
user_input = flow.preview_image_settings
|
||||
user_input = flow.preview_cam
|
||||
camera = GenericCamera(self.hass, user_input, flow_id, "preview")
|
||||
if not camera.is_on:
|
||||
_LOGGER.debug("Camera is off")
|
||||
@@ -567,7 +583,7 @@ async def ws_start_preview(
|
||||
GenericOptionsFlowHandler,
|
||||
hass.config_entries.options._progress.get(flow_id), # noqa: SLF001
|
||||
)
|
||||
user_input = flow.preview_image_settings
|
||||
user_input = flow.preview_cam
|
||||
|
||||
# Create an EntityPlatform, needed for name translations
|
||||
platform = await async_prepare_setup_platform(hass, {}, CAMERA_DOMAIN, DOMAIN)
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/generic",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["av==13.1.0", "Pillow==11.1.0"]
|
||||
"requirements": ["av==13.1.0", "Pillow==11.0.0"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/google",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["googleapiclient"],
|
||||
"requirements": ["gcal-sync==7.0.0", "oauth2client==4.1.3", "ical==8.3.0"]
|
||||
"requirements": ["gcal-sync==6.2.0", "oauth2client==4.1.3", "ical==8.2.0"]
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"fields": {
|
||||
"agent_user_id": {
|
||||
"name": "Agent user ID",
|
||||
"description": "Only needed for automations. Specific Home Assistant user ID (not username, ID in Settings > People > Users > under username) to sync with Google Assistant. Not needed when you use this action through Home Assistant frontend or API. Used in automation, script or other place where context.user_id is missing."
|
||||
"description": "Only needed for automations. Specific Home Assistant user id (not username, ID in configuration > users > under username) to sync with Google Assistant. Do not need when you use this action through Home Assistant front end or API. Used in automation script or other place where context.user_id is missing."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,11 +66,11 @@
|
||||
"services": {
|
||||
"upload": {
|
||||
"name": "Upload media",
|
||||
"description": "Uploads images or videos to Google Photos.",
|
||||
"description": "Upload images or videos to Google Photos.",
|
||||
"fields": {
|
||||
"config_entry_id": {
|
||||
"name": "Integration ID",
|
||||
"description": "The Google Photos integration ID."
|
||||
"name": "Integration Id",
|
||||
"description": "The Google Photos integration id."
|
||||
},
|
||||
"filename": {
|
||||
"name": "Filename",
|
||||
|
||||
@@ -238,7 +238,7 @@
|
||||
},
|
||||
"set": {
|
||||
"name": "Set",
|
||||
"description": "Creates/Updates a group.",
|
||||
"description": "Creates/Updates a user group.",
|
||||
"fields": {
|
||||
"object_id": {
|
||||
"name": "Object ID",
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
"""The habitica integration."""
|
||||
|
||||
from habiticalib import Habitica
|
||||
from http import HTTPStatus
|
||||
|
||||
from aiohttp import ClientResponseError
|
||||
from habitipy.aio import HabitipyAsync
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform
|
||||
from homeassistant.const import (
|
||||
APPLICATION_NAME,
|
||||
CONF_API_KEY,
|
||||
CONF_NAME,
|
||||
CONF_URL,
|
||||
CONF_VERIFY_SSL,
|
||||
Platform,
|
||||
__version__,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_API_USER, DOMAIN, X_CLIENT
|
||||
from .const import CONF_API_USER, DEVELOPER_ID, DOMAIN
|
||||
from .coordinator import HabiticaDataUpdateCoordinator
|
||||
from .services import async_setup_services
|
||||
from .types import HabiticaConfigEntry
|
||||
@@ -21,7 +33,6 @@ PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.CALENDAR,
|
||||
Platform.IMAGE,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.TODO,
|
||||
@@ -40,17 +51,47 @@ async def async_setup_entry(
|
||||
) -> bool:
|
||||
"""Set up habitica from a config entry."""
|
||||
|
||||
session = async_get_clientsession(
|
||||
class HAHabitipyAsync(HabitipyAsync):
|
||||
"""Closure API class to hold session."""
|
||||
|
||||
def __call__(self, **kwargs):
|
||||
return super().__call__(websession, **kwargs)
|
||||
|
||||
def _make_headers(self) -> dict[str, str]:
|
||||
headers = super()._make_headers()
|
||||
headers.update(
|
||||
{"x-client": f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}"}
|
||||
)
|
||||
return headers
|
||||
|
||||
websession = async_get_clientsession(
|
||||
hass, verify_ssl=config_entry.data.get(CONF_VERIFY_SSL, True)
|
||||
)
|
||||
|
||||
api = Habitica(
|
||||
session,
|
||||
api_user=config_entry.data[CONF_API_USER],
|
||||
api_key=config_entry.data[CONF_API_KEY],
|
||||
url=config_entry.data[CONF_URL],
|
||||
x_client=X_CLIENT,
|
||||
api = await hass.async_add_executor_job(
|
||||
HAHabitipyAsync,
|
||||
{
|
||||
"url": config_entry.data[CONF_URL],
|
||||
"login": config_entry.data[CONF_API_USER],
|
||||
"password": config_entry.data[CONF_API_KEY],
|
||||
},
|
||||
)
|
||||
try:
|
||||
user = await api.user.get(userFields="profile")
|
||||
except ClientResponseError as e:
|
||||
if e.status == HTTPStatus.TOO_MANY_REQUESTS:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_rate_limit_exception",
|
||||
) from e
|
||||
raise ConfigEntryNotReady(e) from e
|
||||
|
||||
if not config_entry.data.get(CONF_NAME):
|
||||
name = user["profile"]["name"]
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry,
|
||||
data={**config_entry.data, CONF_NAME: name},
|
||||
)
|
||||
|
||||
coordinator = HabiticaDataUpdateCoordinator(hass, api)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
@@ -5,8 +5,7 @@ from __future__ import annotations
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
from habiticalib import UserData
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorEntity,
|
||||
@@ -24,8 +23,8 @@ from .types import HabiticaConfigEntry
|
||||
class HabiticaBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Habitica Binary Sensor Description."""
|
||||
|
||||
value_fn: Callable[[UserData], bool | None]
|
||||
entity_picture: Callable[[UserData], str | None]
|
||||
value_fn: Callable[[dict[str, Any]], bool | None]
|
||||
entity_picture: Callable[[dict[str, Any]], str | None]
|
||||
|
||||
|
||||
class HabiticaBinarySensor(StrEnum):
|
||||
@@ -34,10 +33,10 @@ class HabiticaBinarySensor(StrEnum):
|
||||
PENDING_QUEST = "pending_quest"
|
||||
|
||||
|
||||
def get_scroll_image_for_pending_quest_invitation(user: UserData) -> str | None:
|
||||
def get_scroll_image_for_pending_quest_invitation(user: dict[str, Any]) -> str | None:
|
||||
"""Entity picture for pending quest invitation."""
|
||||
if user.party.quest.key and user.party.quest.RSVPNeeded:
|
||||
return f"inventory_quest_scroll_{user.party.quest.key}.png"
|
||||
if user["party"]["quest"].get("key") and user["party"]["quest"]["RSVPNeeded"]:
|
||||
return f"inventory_quest_scroll_{user["party"]["quest"]["key"]}.png"
|
||||
return None
|
||||
|
||||
|
||||
@@ -45,7 +44,7 @@ BINARY_SENSOR_DESCRIPTIONS: tuple[HabiticaBinarySensorEntityDescription, ...] =
|
||||
HabiticaBinarySensorEntityDescription(
|
||||
key=HabiticaBinarySensor.PENDING_QUEST,
|
||||
translation_key=HabiticaBinarySensor.PENDING_QUEST,
|
||||
value_fn=lambda user: user.party.quest.RSVPNeeded,
|
||||
value_fn=lambda user: user["party"]["quest"]["RSVPNeeded"],
|
||||
entity_picture=get_scroll_image_for_pending_quest_invitation,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -5,17 +5,10 @@ from __future__ import annotations
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientError
|
||||
from habiticalib import (
|
||||
HabiticaClass,
|
||||
HabiticaException,
|
||||
NotAuthorizedError,
|
||||
Skill,
|
||||
TaskType,
|
||||
TooManyRequestsError,
|
||||
)
|
||||
from aiohttp import ClientResponseError
|
||||
|
||||
from homeassistant.components.button import (
|
||||
DOMAIN as BUTTON_DOMAIN,
|
||||
@@ -27,7 +20,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import ASSETS_URL, DOMAIN
|
||||
from .const import ASSETS_URL, DOMAIN, HEALER, MAGE, ROGUE, WARRIOR
|
||||
from .coordinator import HabiticaData, HabiticaDataUpdateCoordinator
|
||||
from .entity import HabiticaBase
|
||||
from .types import HabiticaConfigEntry
|
||||
@@ -41,11 +34,11 @@ class HabiticaButtonEntityDescription(ButtonEntityDescription):
|
||||
|
||||
press_fn: Callable[[HabiticaDataUpdateCoordinator], Any]
|
||||
available_fn: Callable[[HabiticaData], bool]
|
||||
class_needed: HabiticaClass | None = None
|
||||
class_needed: str | None = None
|
||||
entity_picture: str | None = None
|
||||
|
||||
|
||||
class HabiticaButtonEntity(StrEnum):
|
||||
class HabitipyButtonEntity(StrEnum):
|
||||
"""Habitica button entities."""
|
||||
|
||||
RUN_CRON = "run_cron"
|
||||
@@ -68,207 +61,205 @@ class HabiticaButtonEntity(StrEnum):
|
||||
|
||||
BUTTON_DESCRIPTIONS: tuple[HabiticaButtonEntityDescription, ...] = (
|
||||
HabiticaButtonEntityDescription(
|
||||
key=HabiticaButtonEntity.RUN_CRON,
|
||||
translation_key=HabiticaButtonEntity.RUN_CRON,
|
||||
press_fn=lambda coordinator: coordinator.habitica.run_cron(),
|
||||
available_fn=lambda data: data.user.needsCron is True,
|
||||
key=HabitipyButtonEntity.RUN_CRON,
|
||||
translation_key=HabitipyButtonEntity.RUN_CRON,
|
||||
press_fn=lambda coordinator: coordinator.api.cron.post(),
|
||||
available_fn=lambda data: data.user["needsCron"],
|
||||
),
|
||||
HabiticaButtonEntityDescription(
|
||||
key=HabiticaButtonEntity.BUY_HEALTH_POTION,
|
||||
translation_key=HabiticaButtonEntity.BUY_HEALTH_POTION,
|
||||
press_fn=lambda coordinator: coordinator.habitica.buy_health_potion(),
|
||||
key=HabitipyButtonEntity.BUY_HEALTH_POTION,
|
||||
translation_key=HabitipyButtonEntity.BUY_HEALTH_POTION,
|
||||
press_fn=(
|
||||
lambda coordinator: coordinator.api["user"]["buy-health-potion"].post()
|
||||
),
|
||||
available_fn=(
|
||||
lambda data: (data.user.stats.gp or 0) >= 25
|
||||
and (data.user.stats.hp or 0) < 50
|
||||
lambda data: data.user["stats"]["gp"] >= 25
|
||||
and data.user["stats"]["hp"] < 50
|
||||
),
|
||||
entity_picture="shop_potion.png",
|
||||
),
|
||||
HabiticaButtonEntityDescription(
|
||||
key=HabiticaButtonEntity.ALLOCATE_ALL_STAT_POINTS,
|
||||
translation_key=HabiticaButtonEntity.ALLOCATE_ALL_STAT_POINTS,
|
||||
press_fn=lambda coordinator: coordinator.habitica.allocate_stat_points(),
|
||||
key=HabitipyButtonEntity.ALLOCATE_ALL_STAT_POINTS,
|
||||
translation_key=HabitipyButtonEntity.ALLOCATE_ALL_STAT_POINTS,
|
||||
press_fn=lambda coordinator: coordinator.api["user"]["allocate-now"].post(),
|
||||
available_fn=(
|
||||
lambda data: data.user.preferences.automaticAllocation is True
|
||||
and (data.user.stats.points or 0) > 0
|
||||
lambda data: data.user["preferences"].get("automaticAllocation") is True
|
||||
and data.user["stats"]["points"] > 0
|
||||
),
|
||||
),
|
||||
HabiticaButtonEntityDescription(
|
||||
key=HabiticaButtonEntity.REVIVE,
|
||||
translation_key=HabiticaButtonEntity.REVIVE,
|
||||
press_fn=lambda coordinator: coordinator.habitica.revive(),
|
||||
available_fn=lambda data: data.user.stats.hp == 0,
|
||||
key=HabitipyButtonEntity.REVIVE,
|
||||
translation_key=HabitipyButtonEntity.REVIVE,
|
||||
press_fn=lambda coordinator: coordinator.api["user"]["revive"].post(),
|
||||
available_fn=lambda data: data.user["stats"]["hp"] == 0,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = (
|
||||
HabiticaButtonEntityDescription(
|
||||
key=HabiticaButtonEntity.MPHEAL,
|
||||
translation_key=HabiticaButtonEntity.MPHEAL,
|
||||
press_fn=(
|
||||
lambda coordinator: coordinator.habitica.cast_skill(Skill.ETHEREAL_SURGE)
|
||||
),
|
||||
key=HabitipyButtonEntity.MPHEAL,
|
||||
translation_key=HabitipyButtonEntity.MPHEAL,
|
||||
press_fn=lambda coordinator: coordinator.api.user.class_.cast["mpheal"].post(),
|
||||
available_fn=(
|
||||
lambda data: (data.user.stats.lvl or 0) >= 12
|
||||
and (data.user.stats.mp or 0) >= 30
|
||||
lambda data: data.user["stats"]["lvl"] >= 12
|
||||
and data.user["stats"]["mp"] >= 30
|
||||
),
|
||||
class_needed=HabiticaClass.MAGE,
|
||||
class_needed=MAGE,
|
||||
entity_picture="shop_mpheal.png",
|
||||
),
|
||||
HabiticaButtonEntityDescription(
|
||||
key=HabiticaButtonEntity.EARTH,
|
||||
translation_key=HabiticaButtonEntity.EARTH,
|
||||
press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.EARTHQUAKE),
|
||||
key=HabitipyButtonEntity.EARTH,
|
||||
translation_key=HabitipyButtonEntity.EARTH,
|
||||
press_fn=lambda coordinator: coordinator.api.user.class_.cast["earth"].post(),
|
||||
available_fn=(
|
||||
lambda data: (data.user.stats.lvl or 0) >= 13
|
||||
and (data.user.stats.mp or 0) >= 35
|
||||
lambda data: data.user["stats"]["lvl"] >= 13
|
||||
and data.user["stats"]["mp"] >= 35
|
||||
),
|
||||
class_needed=HabiticaClass.MAGE,
|
||||
class_needed=MAGE,
|
||||
entity_picture="shop_earth.png",
|
||||
),
|
||||
HabiticaButtonEntityDescription(
|
||||
key=HabiticaButtonEntity.FROST,
|
||||
translation_key=HabiticaButtonEntity.FROST,
|
||||
press_fn=(
|
||||
lambda coordinator: coordinator.habitica.cast_skill(Skill.CHILLING_FROST)
|
||||
),
|
||||
key=HabitipyButtonEntity.FROST,
|
||||
translation_key=HabitipyButtonEntity.FROST,
|
||||
press_fn=lambda coordinator: coordinator.api.user.class_.cast["frost"].post(),
|
||||
# chilling frost can only be cast once per day (streaks buff is false)
|
||||
available_fn=(
|
||||
lambda data: (data.user.stats.lvl or 0) >= 14
|
||||
and (data.user.stats.mp or 0) >= 40
|
||||
and not data.user.stats.buffs.streaks
|
||||
lambda data: data.user["stats"]["lvl"] >= 14
|
||||
and data.user["stats"]["mp"] >= 40
|
||||
and not data.user["stats"]["buffs"]["streaks"]
|
||||
),
|
||||
class_needed=HabiticaClass.MAGE,
|
||||
class_needed=MAGE,
|
||||
entity_picture="shop_frost.png",
|
||||
),
|
||||
HabiticaButtonEntityDescription(
|
||||
key=HabiticaButtonEntity.DEFENSIVE_STANCE,
|
||||
translation_key=HabiticaButtonEntity.DEFENSIVE_STANCE,
|
||||
key=HabitipyButtonEntity.DEFENSIVE_STANCE,
|
||||
translation_key=HabitipyButtonEntity.DEFENSIVE_STANCE,
|
||||
press_fn=(
|
||||
lambda coordinator: coordinator.habitica.cast_skill(Skill.DEFENSIVE_STANCE)
|
||||
lambda coordinator: coordinator.api.user.class_.cast[
|
||||
"defensiveStance"
|
||||
].post()
|
||||
),
|
||||
available_fn=(
|
||||
lambda data: (data.user.stats.lvl or 0) >= 12
|
||||
and (data.user.stats.mp or 0) >= 25
|
||||
lambda data: data.user["stats"]["lvl"] >= 12
|
||||
and data.user["stats"]["mp"] >= 25
|
||||
),
|
||||
class_needed=HabiticaClass.WARRIOR,
|
||||
class_needed=WARRIOR,
|
||||
entity_picture="shop_defensiveStance.png",
|
||||
),
|
||||
HabiticaButtonEntityDescription(
|
||||
key=HabiticaButtonEntity.VALOROUS_PRESENCE,
|
||||
translation_key=HabiticaButtonEntity.VALOROUS_PRESENCE,
|
||||
key=HabitipyButtonEntity.VALOROUS_PRESENCE,
|
||||
translation_key=HabitipyButtonEntity.VALOROUS_PRESENCE,
|
||||
press_fn=(
|
||||
lambda coordinator: coordinator.habitica.cast_skill(Skill.VALOROUS_PRESENCE)
|
||||
lambda coordinator: coordinator.api.user.class_.cast[
|
||||
"valorousPresence"
|
||||
].post()
|
||||
),
|
||||
available_fn=(
|
||||
lambda data: (data.user.stats.lvl or 0) >= 13
|
||||
and (data.user.stats.mp or 0) >= 20
|
||||
lambda data: data.user["stats"]["lvl"] >= 13
|
||||
and data.user["stats"]["mp"] >= 20
|
||||
),
|
||||
class_needed=HabiticaClass.WARRIOR,
|
||||
class_needed=WARRIOR,
|
||||
entity_picture="shop_valorousPresence.png",
|
||||
),
|
||||
HabiticaButtonEntityDescription(
|
||||
key=HabiticaButtonEntity.INTIMIDATE,
|
||||
translation_key=HabiticaButtonEntity.INTIMIDATE,
|
||||
key=HabitipyButtonEntity.INTIMIDATE,
|
||||
translation_key=HabitipyButtonEntity.INTIMIDATE,
|
||||
press_fn=(
|
||||
lambda coordinator: coordinator.habitica.cast_skill(Skill.INTIMIDATING_GAZE)
|
||||
lambda coordinator: coordinator.api.user.class_.cast["intimidate"].post()
|
||||
),
|
||||
available_fn=(
|
||||
lambda data: (data.user.stats.lvl or 0) >= 14
|
||||
and (data.user.stats.mp or 0) >= 15
|
||||
lambda data: data.user["stats"]["lvl"] >= 14
|
||||
and data.user["stats"]["mp"] >= 15
|
||||
),
|
||||
class_needed=HabiticaClass.WARRIOR,
|
||||
class_needed=WARRIOR,
|
||||
entity_picture="shop_intimidate.png",
|
||||
),
|
||||
HabiticaButtonEntityDescription(
|
||||
key=HabiticaButtonEntity.TOOLS_OF_TRADE,
|
||||
translation_key=HabiticaButtonEntity.TOOLS_OF_TRADE,
|
||||
key=HabitipyButtonEntity.TOOLS_OF_TRADE,
|
||||
translation_key=HabitipyButtonEntity.TOOLS_OF_TRADE,
|
||||
press_fn=(
|
||||
lambda coordinator: coordinator.habitica.cast_skill(
|
||||
Skill.TOOLS_OF_THE_TRADE
|
||||
)
|
||||
lambda coordinator: coordinator.api.user.class_.cast["toolsOfTrade"].post()
|
||||
),
|
||||
available_fn=(
|
||||
lambda data: (data.user.stats.lvl or 0) >= 13
|
||||
and (data.user.stats.mp or 0) >= 25
|
||||
lambda data: data.user["stats"]["lvl"] >= 13
|
||||
and data.user["stats"]["mp"] >= 25
|
||||
),
|
||||
class_needed=HabiticaClass.ROGUE,
|
||||
class_needed=ROGUE,
|
||||
entity_picture="shop_toolsOfTrade.png",
|
||||
),
|
||||
HabiticaButtonEntityDescription(
|
||||
key=HabiticaButtonEntity.STEALTH,
|
||||
translation_key=HabiticaButtonEntity.STEALTH,
|
||||
press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.STEALTH),
|
||||
key=HabitipyButtonEntity.STEALTH,
|
||||
translation_key=HabitipyButtonEntity.STEALTH,
|
||||
press_fn=(
|
||||
lambda coordinator: coordinator.api.user.class_.cast["stealth"].post()
|
||||
),
|
||||
# Stealth buffs stack and it can only be cast if the amount of
|
||||
# buffs is smaller than the amount of unfinished dailies
|
||||
# unfinished dailies is smaller than the amount of buffs
|
||||
available_fn=(
|
||||
lambda data: (data.user.stats.lvl or 0) >= 14
|
||||
and (data.user.stats.mp or 0) >= 45
|
||||
and (data.user.stats.buffs.stealth or 0)
|
||||
lambda data: data.user["stats"]["lvl"] >= 14
|
||||
and data.user["stats"]["mp"] >= 45
|
||||
and data.user["stats"]["buffs"]["stealth"]
|
||||
< len(
|
||||
[
|
||||
r
|
||||
for r in data.tasks
|
||||
if r.Type is TaskType.DAILY
|
||||
and r.isDue is True
|
||||
and r.completed is False
|
||||
if r.get("type") == "daily"
|
||||
and r.get("isDue") is True
|
||||
and r.get("completed") is False
|
||||
]
|
||||
)
|
||||
),
|
||||
class_needed=HabiticaClass.ROGUE,
|
||||
class_needed=ROGUE,
|
||||
entity_picture="shop_stealth.png",
|
||||
),
|
||||
HabiticaButtonEntityDescription(
|
||||
key=HabiticaButtonEntity.HEAL,
|
||||
translation_key=HabiticaButtonEntity.HEAL,
|
||||
press_fn=(
|
||||
lambda coordinator: coordinator.habitica.cast_skill(Skill.HEALING_LIGHT)
|
||||
),
|
||||
key=HabitipyButtonEntity.HEAL,
|
||||
translation_key=HabitipyButtonEntity.HEAL,
|
||||
press_fn=lambda coordinator: coordinator.api.user.class_.cast["heal"].post(),
|
||||
available_fn=(
|
||||
lambda data: (data.user.stats.lvl or 0) >= 11
|
||||
and (data.user.stats.mp or 0) >= 15
|
||||
and (data.user.stats.hp or 0) < 50
|
||||
lambda data: data.user["stats"]["lvl"] >= 11
|
||||
and data.user["stats"]["mp"] >= 15
|
||||
and data.user["stats"]["hp"] < 50
|
||||
),
|
||||
class_needed=HabiticaClass.HEALER,
|
||||
class_needed=HEALER,
|
||||
entity_picture="shop_heal.png",
|
||||
),
|
||||
HabiticaButtonEntityDescription(
|
||||
key=HabiticaButtonEntity.BRIGHTNESS,
|
||||
translation_key=HabiticaButtonEntity.BRIGHTNESS,
|
||||
key=HabitipyButtonEntity.BRIGHTNESS,
|
||||
translation_key=HabitipyButtonEntity.BRIGHTNESS,
|
||||
press_fn=(
|
||||
lambda coordinator: coordinator.habitica.cast_skill(
|
||||
Skill.SEARING_BRIGHTNESS
|
||||
)
|
||||
lambda coordinator: coordinator.api.user.class_.cast["brightness"].post()
|
||||
),
|
||||
available_fn=(
|
||||
lambda data: (data.user.stats.lvl or 0) >= 12
|
||||
and (data.user.stats.mp or 0) >= 15
|
||||
lambda data: data.user["stats"]["lvl"] >= 12
|
||||
and data.user["stats"]["mp"] >= 15
|
||||
),
|
||||
class_needed=HabiticaClass.HEALER,
|
||||
class_needed=HEALER,
|
||||
entity_picture="shop_brightness.png",
|
||||
),
|
||||
HabiticaButtonEntityDescription(
|
||||
key=HabiticaButtonEntity.PROTECT_AURA,
|
||||
translation_key=HabiticaButtonEntity.PROTECT_AURA,
|
||||
key=HabitipyButtonEntity.PROTECT_AURA,
|
||||
translation_key=HabitipyButtonEntity.PROTECT_AURA,
|
||||
press_fn=(
|
||||
lambda coordinator: coordinator.habitica.cast_skill(Skill.PROTECTIVE_AURA)
|
||||
lambda coordinator: coordinator.api.user.class_.cast["protectAura"].post()
|
||||
),
|
||||
available_fn=(
|
||||
lambda data: (data.user.stats.lvl or 0) >= 13
|
||||
and (data.user.stats.mp or 0) >= 30
|
||||
lambda data: data.user["stats"]["lvl"] >= 13
|
||||
and data.user["stats"]["mp"] >= 30
|
||||
),
|
||||
class_needed=HabiticaClass.HEALER,
|
||||
class_needed=HEALER,
|
||||
entity_picture="shop_protectAura.png",
|
||||
),
|
||||
HabiticaButtonEntityDescription(
|
||||
key=HabiticaButtonEntity.HEAL_ALL,
|
||||
translation_key=HabiticaButtonEntity.HEAL_ALL,
|
||||
press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.BLESSING),
|
||||
key=HabitipyButtonEntity.HEAL_ALL,
|
||||
translation_key=HabitipyButtonEntity.HEAL_ALL,
|
||||
press_fn=lambda coordinator: coordinator.api.user.class_.cast["healAll"].post(),
|
||||
available_fn=(
|
||||
lambda data: (data.user.stats.lvl or 0) >= 14
|
||||
and (data.user.stats.mp or 0) >= 25
|
||||
lambda data: data.user["stats"]["lvl"] >= 14
|
||||
and data.user["stats"]["mp"] >= 25
|
||||
),
|
||||
class_needed=HabiticaClass.HEALER,
|
||||
class_needed=HEALER,
|
||||
entity_picture="shop_healAll.png",
|
||||
),
|
||||
)
|
||||
@@ -294,10 +285,10 @@ async def async_setup_entry(
|
||||
|
||||
for description in CLASS_SKILLS:
|
||||
if (
|
||||
(coordinator.data.user.stats.lvl or 0) >= 10
|
||||
and coordinator.data.user.flags.classSelected
|
||||
and not coordinator.data.user.preferences.disableClasses
|
||||
and description.class_needed is coordinator.data.user.stats.Class
|
||||
coordinator.data.user["stats"]["lvl"] >= 10
|
||||
and coordinator.data.user["flags"]["classSelected"]
|
||||
and not coordinator.data.user["preferences"]["disableClasses"]
|
||||
and description.class_needed == coordinator.data.user["stats"]["class"]
|
||||
):
|
||||
if description.key not in skills_added:
|
||||
buttons.append(HabiticaButton(coordinator, description))
|
||||
@@ -331,17 +322,17 @@ class HabiticaButton(HabiticaBase, ButtonEntity):
|
||||
"""Handle the button press."""
|
||||
try:
|
||||
await self.entity_description.press_fn(self.coordinator)
|
||||
except TooManyRequestsError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_rate_limit_exception",
|
||||
) from e
|
||||
except NotAuthorizedError as e:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_call_unallowed",
|
||||
) from e
|
||||
except (HabiticaException, ClientError) as e:
|
||||
except ClientResponseError as e:
|
||||
if e.status == HTTPStatus.TOO_MANY_REQUESTS:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_rate_limit_exception",
|
||||
) from e
|
||||
if e.status == HTTPStatus.UNAUTHORIZED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_call_unallowed",
|
||||
) from e
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_call_exception",
|
||||
|
||||
@@ -5,11 +5,8 @@ from __future__ import annotations
|
||||
from abc import abstractmethod
|
||||
from datetime import date, datetime, timedelta
|
||||
from enum import StrEnum
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
from dateutil.rrule import rrule
|
||||
from habiticalib import TaskType
|
||||
|
||||
from homeassistant.components.calendar import (
|
||||
CalendarEntity,
|
||||
@@ -23,6 +20,7 @@ from homeassistant.util import dt as dt_util
|
||||
from . import HabiticaConfigEntry
|
||||
from .coordinator import HabiticaDataUpdateCoordinator
|
||||
from .entity import HabiticaBase
|
||||
from .types import HabiticaTaskType
|
||||
from .util import build_rrule, get_recurrence_rule
|
||||
|
||||
|
||||
@@ -85,7 +83,9 @@ class HabiticaCalendarEntity(HabiticaBase, CalendarEntity):
|
||||
@property
|
||||
def start_of_today(self) -> datetime:
|
||||
"""Habitica daystart."""
|
||||
return dt_util.start_of_local_day(self.coordinator.data.user.lastCron)
|
||||
return dt_util.start_of_local_day(
|
||||
datetime.fromisoformat(self.coordinator.data.user["lastCron"])
|
||||
)
|
||||
|
||||
def get_recurrence_dates(
|
||||
self, recurrences: rrule, start_date: datetime, end_date: datetime | None = None
|
||||
@@ -115,13 +115,13 @@ class HabiticaTodosCalendarEntity(HabiticaCalendarEntity):
|
||||
events = []
|
||||
for task in self.coordinator.data.tasks:
|
||||
if not (
|
||||
task.Type is TaskType.TODO
|
||||
and not task.completed
|
||||
and task.date is not None # only if has due date
|
||||
task["type"] == HabiticaTaskType.TODO
|
||||
and not task["completed"]
|
||||
and task.get("date") # only if has due date
|
||||
):
|
||||
continue
|
||||
|
||||
start = dt_util.start_of_local_day(task.date)
|
||||
start = dt_util.start_of_local_day(datetime.fromisoformat(task["date"]))
|
||||
end = start + timedelta(days=1)
|
||||
# return current and upcoming events or events within the requested range
|
||||
|
||||
@@ -132,23 +132,21 @@ class HabiticaTodosCalendarEntity(HabiticaCalendarEntity):
|
||||
if end_date and start > end_date:
|
||||
# Event starts after date range
|
||||
continue
|
||||
if TYPE_CHECKING:
|
||||
assert task.text
|
||||
assert task.id
|
||||
|
||||
events.append(
|
||||
CalendarEvent(
|
||||
start=start.date(),
|
||||
end=end.date(),
|
||||
summary=task.text,
|
||||
description=task.notes,
|
||||
uid=str(task.id),
|
||||
summary=task["text"],
|
||||
description=task["notes"],
|
||||
uid=task["id"],
|
||||
)
|
||||
)
|
||||
return sorted(
|
||||
events,
|
||||
key=lambda event: (
|
||||
event.start,
|
||||
self.coordinator.data.user.tasksOrder.todos.index(UUID(event.uid)),
|
||||
self.coordinator.data.user["tasksOrder"]["todos"].index(event.uid),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -191,7 +189,7 @@ class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity):
|
||||
events = []
|
||||
for task in self.coordinator.data.tasks:
|
||||
# only dailies that that are not 'grey dailies'
|
||||
if not (task.Type is TaskType.DAILY and task.everyX):
|
||||
if not (task["type"] == HabiticaTaskType.DAILY and task["everyX"]):
|
||||
continue
|
||||
|
||||
recurrences = build_rrule(task)
|
||||
@@ -201,21 +199,19 @@ class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity):
|
||||
for recurrence in recurrence_dates:
|
||||
is_future_event = recurrence > self.start_of_today
|
||||
is_current_event = (
|
||||
recurrence <= self.start_of_today and not task.completed
|
||||
recurrence <= self.start_of_today and not task["completed"]
|
||||
)
|
||||
|
||||
if not is_future_event and not is_current_event:
|
||||
continue
|
||||
if TYPE_CHECKING:
|
||||
assert task.text
|
||||
assert task.id
|
||||
|
||||
events.append(
|
||||
CalendarEvent(
|
||||
start=recurrence.date(),
|
||||
end=self.end_date(recurrence, end_date),
|
||||
summary=task.text,
|
||||
description=task.notes,
|
||||
uid=str(task.id),
|
||||
summary=task["text"],
|
||||
description=task["notes"],
|
||||
uid=task["id"],
|
||||
rrule=get_recurrence_rule(recurrences),
|
||||
)
|
||||
)
|
||||
@@ -223,7 +219,7 @@ class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity):
|
||||
events,
|
||||
key=lambda event: (
|
||||
event.start,
|
||||
self.coordinator.data.user.tasksOrder.dailys.index(UUID(event.uid)),
|
||||
self.coordinator.data.user["tasksOrder"]["dailys"].index(event.uid),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -258,14 +254,14 @@ class HabiticaTodoRemindersCalendarEntity(HabiticaCalendarEntity):
|
||||
events = []
|
||||
|
||||
for task in self.coordinator.data.tasks:
|
||||
if task.Type is not TaskType.TODO or task.completed:
|
||||
if task["type"] != HabiticaTaskType.TODO or task["completed"]:
|
||||
continue
|
||||
|
||||
for reminder in task.reminders:
|
||||
for reminder in task.get("reminders", []):
|
||||
# reminders are returned by the API in local time but with wrong
|
||||
# timezone (UTC) and arbitrary added seconds/microseconds. When
|
||||
# creating reminders in Habitica only hours and minutes can be defined.
|
||||
start = reminder.time.replace(
|
||||
start = datetime.fromisoformat(reminder["time"]).replace(
|
||||
tzinfo=dt_util.DEFAULT_TIME_ZONE, second=0, microsecond=0
|
||||
)
|
||||
end = start + timedelta(hours=1)
|
||||
@@ -277,16 +273,14 @@ class HabiticaTodoRemindersCalendarEntity(HabiticaCalendarEntity):
|
||||
if end_date and start > end_date:
|
||||
# Event starts after date range
|
||||
continue
|
||||
if TYPE_CHECKING:
|
||||
assert task.text
|
||||
assert task.id
|
||||
|
||||
events.append(
|
||||
CalendarEvent(
|
||||
start=start,
|
||||
end=end,
|
||||
summary=task.text,
|
||||
description=task.notes,
|
||||
uid=f"{task.id}_{reminder.id}",
|
||||
summary=task["text"],
|
||||
description=task["notes"],
|
||||
uid=f"{task["id"]}_{reminder["id"]}",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -304,7 +298,7 @@ class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity):
|
||||
translation_key=HabiticaCalendar.DAILY_REMINDERS,
|
||||
)
|
||||
|
||||
def start(self, reminder_time: datetime, reminder_date: date) -> datetime:
|
||||
def start(self, reminder_time: str, reminder_date: date) -> datetime:
|
||||
"""Generate reminder times for dailies.
|
||||
|
||||
Reminders for dailies have a datetime but the date part is arbitrary,
|
||||
@@ -313,10 +307,12 @@ class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity):
|
||||
"""
|
||||
return datetime.combine(
|
||||
reminder_date,
|
||||
reminder_time.replace(
|
||||
datetime.fromisoformat(reminder_time)
|
||||
.replace(
|
||||
second=0,
|
||||
microsecond=0,
|
||||
).time(),
|
||||
)
|
||||
.time(),
|
||||
tzinfo=dt_util.DEFAULT_TIME_ZONE,
|
||||
)
|
||||
|
||||
@@ -331,7 +327,7 @@ class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity):
|
||||
start_date = max(start_date, self.start_of_today)
|
||||
|
||||
for task in self.coordinator.data.tasks:
|
||||
if not (task.Type is TaskType.DAILY and task.everyX):
|
||||
if not (task["type"] == HabiticaTaskType.DAILY and task["everyX"]):
|
||||
continue
|
||||
|
||||
recurrences = build_rrule(task)
|
||||
@@ -343,30 +339,27 @@ class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity):
|
||||
for recurrence in recurrence_dates:
|
||||
is_future_event = recurrence > self.start_of_today
|
||||
is_current_event = (
|
||||
recurrence <= self.start_of_today and not task.completed
|
||||
recurrence <= self.start_of_today and not task["completed"]
|
||||
)
|
||||
|
||||
if not is_future_event and not is_current_event:
|
||||
continue
|
||||
|
||||
for reminder in task.reminders:
|
||||
start = self.start(reminder.time, recurrence)
|
||||
for reminder in task.get("reminders", []):
|
||||
start = self.start(reminder["time"], recurrence)
|
||||
end = start + timedelta(hours=1)
|
||||
|
||||
if end < start_date:
|
||||
# Event ends before date range
|
||||
continue
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert task.id
|
||||
assert task.text
|
||||
events.append(
|
||||
CalendarEvent(
|
||||
start=start,
|
||||
end=end,
|
||||
summary=task.text,
|
||||
description=task.notes,
|
||||
uid=f"{task.id}_{reminder.id}",
|
||||
summary=task["text"],
|
||||
description=task["notes"],
|
||||
uid=f"{task["id"]}_{reminder["id"]}",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -2,25 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientError
|
||||
from habiticalib import (
|
||||
Habitica,
|
||||
HabiticaException,
|
||||
LoginData,
|
||||
NotAuthorizedError,
|
||||
UserData,
|
||||
)
|
||||
from aiohttp import ClientResponseError
|
||||
from habitipy.aio import HabitipyAsync
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_URL,
|
||||
CONF_USERNAME,
|
||||
@@ -33,18 +25,14 @@ from homeassistant.helpers.selector import (
|
||||
TextSelectorType,
|
||||
)
|
||||
|
||||
from . import HabiticaConfigEntry
|
||||
from .const import (
|
||||
CONF_API_USER,
|
||||
DEFAULT_URL,
|
||||
DOMAIN,
|
||||
FORGOT_PASSWORD_URL,
|
||||
HABITICANS_URL,
|
||||
SECTION_REAUTH_API_KEY,
|
||||
SECTION_REAUTH_LOGIN,
|
||||
SIGN_UP_URL,
|
||||
SITE_DATA_URL,
|
||||
X_CLIENT,
|
||||
)
|
||||
|
||||
STEP_ADVANCED_DATA_SCHEMA = vol.Schema(
|
||||
@@ -73,44 +61,14 @@ STEP_LOGIN_DATA_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
STEP_REAUTH_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(SECTION_REAUTH_LOGIN): data_entry_flow.section(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_USERNAME): TextSelector(
|
||||
TextSelectorConfig(
|
||||
type=TextSelectorType.EMAIL,
|
||||
autocomplete="email",
|
||||
)
|
||||
),
|
||||
vol.Optional(CONF_PASSWORD): TextSelector(
|
||||
TextSelectorConfig(
|
||||
type=TextSelectorType.PASSWORD,
|
||||
autocomplete="current-password",
|
||||
)
|
||||
),
|
||||
},
|
||||
),
|
||||
{"collapsed": False},
|
||||
),
|
||||
vol.Required(SECTION_REAUTH_API_KEY): data_entry_flow.section(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_API_KEY): str,
|
||||
},
|
||||
),
|
||||
{"collapsed": True},
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for habitica."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -135,20 +93,39 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
errors, login, user = await self.validate_login(
|
||||
{**user_input, CONF_URL: DEFAULT_URL}
|
||||
)
|
||||
if not errors and login is not None and user is not None:
|
||||
await self.async_set_unique_id(str(login.id))
|
||||
try:
|
||||
session = async_get_clientsession(self.hass)
|
||||
api = await self.hass.async_add_executor_job(
|
||||
HabitipyAsync,
|
||||
{
|
||||
"login": "",
|
||||
"password": "",
|
||||
"url": DEFAULT_URL,
|
||||
},
|
||||
)
|
||||
login_response = await api.user.auth.local.login.post(
|
||||
session=session,
|
||||
username=user_input[CONF_USERNAME],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
)
|
||||
|
||||
except ClientResponseError as ex:
|
||||
if ex.status == HTTPStatus.UNAUTHORIZED:
|
||||
errors["base"] = "invalid_auth"
|
||||
else:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(login_response["id"])
|
||||
self._abort_if_unique_id_configured()
|
||||
if TYPE_CHECKING:
|
||||
assert user.profile.name
|
||||
return self.async_create_entry(
|
||||
title=user.profile.name,
|
||||
title=login_response["username"],
|
||||
data={
|
||||
CONF_API_USER: str(login.id),
|
||||
CONF_API_KEY: login.apiToken,
|
||||
CONF_NAME: user.profile.name, # needed for api_call action
|
||||
CONF_API_USER: login_response["id"],
|
||||
CONF_API_KEY: login_response["apiToken"],
|
||||
CONF_USERNAME: login_response["username"],
|
||||
CONF_URL: DEFAULT_URL,
|
||||
CONF_VERIFY_SSL: True,
|
||||
},
|
||||
@@ -173,20 +150,37 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
await self.async_set_unique_id(user_input[CONF_API_USER])
|
||||
self._abort_if_unique_id_configured()
|
||||
errors, user = await self.validate_api_key(user_input)
|
||||
if not errors and user is not None:
|
||||
if TYPE_CHECKING:
|
||||
assert user.profile.name
|
||||
return self.async_create_entry(
|
||||
title=user.profile.name,
|
||||
data={
|
||||
**user_input,
|
||||
CONF_URL: user_input.get(CONF_URL, DEFAULT_URL),
|
||||
CONF_NAME: user.profile.name, # needed for api_call action
|
||||
try:
|
||||
session = async_get_clientsession(
|
||||
self.hass, verify_ssl=user_input.get(CONF_VERIFY_SSL, True)
|
||||
)
|
||||
api = await self.hass.async_add_executor_job(
|
||||
HabitipyAsync,
|
||||
{
|
||||
"login": user_input[CONF_API_USER],
|
||||
"password": user_input[CONF_API_KEY],
|
||||
"url": user_input.get(CONF_URL, DEFAULT_URL),
|
||||
},
|
||||
)
|
||||
api_response = await api.user.get(
|
||||
session=session,
|
||||
userFields="auth",
|
||||
)
|
||||
except ClientResponseError as ex:
|
||||
if ex.status == HTTPStatus.UNAUTHORIZED:
|
||||
errors["base"] = "invalid_auth"
|
||||
else:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(user_input[CONF_API_USER])
|
||||
self._abort_if_unique_id_configured()
|
||||
user_input[CONF_USERNAME] = api_response["auth"]["local"]["username"]
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_USERNAME], data=user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="advanced",
|
||||
@@ -199,120 +193,3 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"default_url": DEFAULT_URL,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Perform reauth upon an API authentication error."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Dialog that informs the user that reauth is required."""
|
||||
errors: dict[str, str] = {}
|
||||
reauth_entry: HabiticaConfigEntry = self._get_reauth_entry()
|
||||
|
||||
if user_input is not None:
|
||||
if user_input[SECTION_REAUTH_LOGIN].get(CONF_USERNAME) and user_input[
|
||||
SECTION_REAUTH_LOGIN
|
||||
].get(CONF_PASSWORD):
|
||||
errors, login, _ = await self.validate_login(
|
||||
{**reauth_entry.data, **user_input[SECTION_REAUTH_LOGIN]}
|
||||
)
|
||||
if not errors and login is not None:
|
||||
await self.async_set_unique_id(str(login.id))
|
||||
self._abort_if_unique_id_mismatch()
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
data_updates={CONF_API_KEY: login.apiToken},
|
||||
)
|
||||
elif user_input[SECTION_REAUTH_API_KEY].get(CONF_API_KEY):
|
||||
errors, user = await self.validate_api_key(
|
||||
{
|
||||
**reauth_entry.data,
|
||||
**user_input[SECTION_REAUTH_API_KEY],
|
||||
}
|
||||
)
|
||||
if not errors and user is not None:
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry, data_updates=user_input[SECTION_REAUTH_API_KEY]
|
||||
)
|
||||
else:
|
||||
errors["base"] = "invalid_credentials"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
data_schema=STEP_REAUTH_DATA_SCHEMA,
|
||||
suggested_values={
|
||||
CONF_USERNAME: (
|
||||
user_input[SECTION_REAUTH_LOGIN].get(CONF_USERNAME)
|
||||
if user_input
|
||||
else None,
|
||||
)
|
||||
},
|
||||
),
|
||||
description_placeholders={
|
||||
CONF_NAME: reauth_entry.title,
|
||||
"habiticans": HABITICANS_URL,
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def validate_login(
|
||||
self, user_input: Mapping[str, Any]
|
||||
) -> tuple[dict[str, str], LoginData | None, UserData | None]:
|
||||
"""Validate login with login credentials."""
|
||||
errors: dict[str, str] = {}
|
||||
session = async_get_clientsession(
|
||||
self.hass, verify_ssl=user_input.get(CONF_VERIFY_SSL, True)
|
||||
)
|
||||
api = Habitica(session=session, x_client=X_CLIENT)
|
||||
try:
|
||||
login = await api.login(
|
||||
username=user_input[CONF_USERNAME],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
)
|
||||
user = await api.get_user(user_fields="profile")
|
||||
|
||||
except NotAuthorizedError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except (HabiticaException, ClientError):
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return errors, login.data, user.data
|
||||
|
||||
return errors, None, None
|
||||
|
||||
async def validate_api_key(
|
||||
self, user_input: Mapping[str, Any]
|
||||
) -> tuple[dict[str, str], UserData | None]:
|
||||
"""Validate authentication with api key."""
|
||||
errors: dict[str, str] = {}
|
||||
session = async_get_clientsession(
|
||||
self.hass, verify_ssl=user_input.get(CONF_VERIFY_SSL, True)
|
||||
)
|
||||
api = Habitica(
|
||||
session=session,
|
||||
x_client=X_CLIENT,
|
||||
api_user=user_input[CONF_API_USER],
|
||||
api_key=user_input[CONF_API_KEY],
|
||||
url=user_input.get(CONF_URL, DEFAULT_URL),
|
||||
)
|
||||
try:
|
||||
user = await api.get_user(user_fields="profile")
|
||||
except NotAuthorizedError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except (HabiticaException, ClientError):
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return errors, user.data
|
||||
|
||||
return errors, None
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Constants for the habitica integration."""
|
||||
|
||||
from homeassistant.const import APPLICATION_NAME, CONF_PATH, __version__
|
||||
from homeassistant.const import CONF_PATH
|
||||
|
||||
CONF_API_USER = "api_user"
|
||||
|
||||
@@ -31,11 +31,6 @@ ATTR_TASK = "task"
|
||||
ATTR_DIRECTION = "direction"
|
||||
ATTR_TARGET = "target"
|
||||
ATTR_ITEM = "item"
|
||||
ATTR_TYPE = "type"
|
||||
ATTR_PRIORITY = "priority"
|
||||
ATTR_TAG = "tag"
|
||||
ATTR_KEYWORD = "keyword"
|
||||
|
||||
SERVICE_CAST_SKILL = "cast_skill"
|
||||
SERVICE_START_QUEST = "start_quest"
|
||||
SERVICE_ACCEPT_QUEST = "accept_quest"
|
||||
@@ -43,16 +38,15 @@ SERVICE_CANCEL_QUEST = "cancel_quest"
|
||||
SERVICE_ABORT_QUEST = "abort_quest"
|
||||
SERVICE_REJECT_QUEST = "reject_quest"
|
||||
SERVICE_LEAVE_QUEST = "leave_quest"
|
||||
SERVICE_GET_TASKS = "get_tasks"
|
||||
|
||||
SERVICE_SCORE_HABIT = "score_habit"
|
||||
SERVICE_SCORE_REWARD = "score_reward"
|
||||
|
||||
SERVICE_TRANSFORMATION = "transformation"
|
||||
|
||||
|
||||
DEVELOPER_ID = "4c4ca53f-c059-4ffa-966e-9d29dd405daf"
|
||||
X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}"
|
||||
WARRIOR = "warrior"
|
||||
ROGUE = "rogue"
|
||||
HEALER = "healer"
|
||||
MAGE = "wizard"
|
||||
|
||||
SECTION_REAUTH_LOGIN = "reauth_login"
|
||||
SECTION_REAUTH_API_KEY = "reauth_api_key"
|
||||
DEVELOPER_ID = "4c4ca53f-c059-4ffa-966e-9d29dd405daf"
|
||||
|
||||
@@ -5,31 +5,16 @@ from __future__ import annotations
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from io import BytesIO
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientError
|
||||
from habiticalib import (
|
||||
ContentData,
|
||||
Habitica,
|
||||
HabiticaException,
|
||||
NotAuthorizedError,
|
||||
TaskData,
|
||||
TaskFilter,
|
||||
TooManyRequestsError,
|
||||
UserData,
|
||||
UserStyles,
|
||||
)
|
||||
from aiohttp import ClientResponseError
|
||||
from habitipy.aio import HabitipyAsync
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryNotReady,
|
||||
HomeAssistantError,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
@@ -40,10 +25,10 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@dataclass
|
||||
class HabiticaData:
|
||||
"""Habitica data."""
|
||||
"""Coordinator data class."""
|
||||
|
||||
user: UserData
|
||||
tasks: list[TaskData]
|
||||
user: dict[str, Any]
|
||||
tasks: list[dict]
|
||||
|
||||
|
||||
class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
|
||||
@@ -51,7 +36,7 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
|
||||
|
||||
config_entry: ConfigEntry
|
||||
|
||||
def __init__(self, hass: HomeAssistant, habitica: Habitica) -> None:
|
||||
def __init__(self, hass: HomeAssistant, habitipy: HabitipyAsync) -> None:
|
||||
"""Initialize the Habitica data coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
@@ -65,53 +50,25 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
|
||||
immediate=False,
|
||||
),
|
||||
)
|
||||
self.habitica = habitica
|
||||
self.content: ContentData
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up Habitica integration."""
|
||||
|
||||
try:
|
||||
user = await self.habitica.get_user()
|
||||
self.content = (
|
||||
await self.habitica.get_content(user.data.preferences.language)
|
||||
).data
|
||||
except NotAuthorizedError as e:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="authentication_failed",
|
||||
) from e
|
||||
except TooManyRequestsError as e:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_rate_limit_exception",
|
||||
) from e
|
||||
except (HabiticaException, ClientError) as e:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_call_exception",
|
||||
) from e
|
||||
|
||||
if not self.config_entry.data.get(CONF_NAME):
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self.config_entry,
|
||||
data={**self.config_entry.data, CONF_NAME: user.data.profile.name},
|
||||
)
|
||||
self.api = habitipy
|
||||
self.content: dict[str, Any] = {}
|
||||
|
||||
async def _async_update_data(self) -> HabiticaData:
|
||||
try:
|
||||
user = (await self.habitica.get_user()).data
|
||||
tasks = (await self.habitica.get_tasks()).data
|
||||
completed_todos = (
|
||||
await self.habitica.get_tasks(TaskFilter.COMPLETED_TODOS)
|
||||
).data
|
||||
except TooManyRequestsError:
|
||||
_LOGGER.debug("Rate limit exceeded, will try again later")
|
||||
return self.data
|
||||
except (HabiticaException, ClientError) as e:
|
||||
raise UpdateFailed(f"Unable to connect to Habitica: {e}") from e
|
||||
else:
|
||||
return HabiticaData(user=user, tasks=tasks + completed_todos)
|
||||
user_response = await self.api.user.get()
|
||||
tasks_response = await self.api.tasks.user.get()
|
||||
tasks_response.extend(await self.api.tasks.user.get(type="completedTodos"))
|
||||
if not self.content:
|
||||
self.content = await self.api.content.get(
|
||||
language=user_response["preferences"]["language"]
|
||||
)
|
||||
except ClientResponseError as error:
|
||||
if error.status == HTTPStatus.TOO_MANY_REQUESTS:
|
||||
_LOGGER.debug("Rate limit exceeded, will try again later")
|
||||
return self.data
|
||||
raise UpdateFailed(f"Unable to connect to Habitica: {error}") from error
|
||||
|
||||
return HabiticaData(user=user_response, tasks=tasks_response)
|
||||
|
||||
async def execute(
|
||||
self, func: Callable[[HabiticaDataUpdateCoordinator], Any]
|
||||
@@ -120,25 +77,15 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
|
||||
|
||||
try:
|
||||
await func(self)
|
||||
except TooManyRequestsError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_rate_limit_exception",
|
||||
) from e
|
||||
except (HabiticaException, ClientError) as e:
|
||||
except ClientResponseError as e:
|
||||
if e.status == HTTPStatus.TOO_MANY_REQUESTS:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_rate_limit_exception",
|
||||
) from e
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_call_exception",
|
||||
) from e
|
||||
else:
|
||||
await self.async_request_refresh()
|
||||
|
||||
async def generate_avatar(self, user_styles: UserStyles) -> bytes:
|
||||
"""Generate Avatar."""
|
||||
|
||||
avatar = BytesIO()
|
||||
await self.habitica.generate_avatar(
|
||||
fp=avatar, user_styles=user_styles, fmt="PNG"
|
||||
)
|
||||
|
||||
return avatar.getvalue()
|
||||
|
||||
@@ -16,12 +16,12 @@ async def async_get_config_entry_diagnostics(
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
|
||||
habitica_data = await config_entry.runtime_data.habitica.get_user_anonymized()
|
||||
habitica_data = await config_entry.runtime_data.api.user.anonymized.get()
|
||||
|
||||
return {
|
||||
"config_entry_data": {
|
||||
CONF_URL: config_entry.data[CONF_URL],
|
||||
CONF_API_USER: config_entry.data[CONF_API_USER],
|
||||
},
|
||||
"habitica_data": habitica_data.to_dict()["data"],
|
||||
"habitica_data": habitica_data,
|
||||
}
|
||||
|
||||
@@ -121,6 +121,12 @@
|
||||
"rogue": "mdi:ninja"
|
||||
}
|
||||
},
|
||||
"todos": {
|
||||
"default": "mdi:checkbox-outline"
|
||||
},
|
||||
"dailys": {
|
||||
"default": "mdi:calendar-month"
|
||||
},
|
||||
"habits": {
|
||||
"default": "mdi:contrast-box"
|
||||
},
|
||||
@@ -190,12 +196,6 @@
|
||||
},
|
||||
"transformation": {
|
||||
"service": "mdi:flask-round-bottom"
|
||||
},
|
||||
"get_tasks": {
|
||||
"service": "mdi:calendar-export",
|
||||
"sections": {
|
||||
"filter": "mdi:calendar-filter"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
"""Image platform for Habitica integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from enum import StrEnum
|
||||
|
||||
from habiticalib import UserStyles
|
||||
|
||||
from homeassistant.components.image import ImageEntity, ImageEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import HabiticaConfigEntry
|
||||
from .coordinator import HabiticaDataUpdateCoordinator
|
||||
from .entity import HabiticaBase
|
||||
|
||||
|
||||
class HabiticaImageEntity(StrEnum):
|
||||
"""Image entities."""
|
||||
|
||||
AVATAR = "avatar"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: HabiticaConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the habitica image platform."""
|
||||
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
async_add_entities([HabiticaImage(hass, coordinator)])
|
||||
|
||||
|
||||
class HabiticaImage(HabiticaBase, ImageEntity):
|
||||
"""A Habitica image entity."""
|
||||
|
||||
entity_description = ImageEntityDescription(
|
||||
key=HabiticaImageEntity.AVATAR,
|
||||
translation_key=HabiticaImageEntity.AVATAR,
|
||||
)
|
||||
_attr_content_type = "image/png"
|
||||
_current_appearance: UserStyles | None = None
|
||||
_cache: bytes | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
coordinator: HabiticaDataUpdateCoordinator,
|
||||
) -> None:
|
||||
"""Initialize the image entity."""
|
||||
super().__init__(coordinator, self.entity_description)
|
||||
ImageEntity.__init__(self, hass)
|
||||
self._attr_image_last_updated = dt_util.utcnow()
|
||||
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Check if equipped gear and other things have changed since last avatar image generation."""
|
||||
new_appearance = UserStyles.from_dict(asdict(self.coordinator.data.user))
|
||||
|
||||
if self._current_appearance != new_appearance:
|
||||
self._current_appearance = new_appearance
|
||||
self._attr_image_last_updated = dt_util.utcnow()
|
||||
self._cache = None
|
||||
|
||||
return super()._handle_coordinator_update()
|
||||
|
||||
async def async_image(self) -> bytes | None:
|
||||
"""Return cached bytes, otherwise generate new avatar."""
|
||||
if not self._cache and self._current_appearance:
|
||||
self._cache = await self.coordinator.generate_avatar(
|
||||
self._current_appearance
|
||||
)
|
||||
return self._cache
|
||||
@@ -5,6 +5,6 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/habitica",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["habiticalib"],
|
||||
"requirements": ["habiticalib==0.3.2"]
|
||||
"loggers": ["habitipy", "plumbum"],
|
||||
"requirements": ["habitipy==0.3.3"]
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ rules:
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
|
||||
@@ -3,57 +3,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import asdict, dataclass
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from habiticalib import (
|
||||
ContentData,
|
||||
HabiticaClass,
|
||||
TaskData,
|
||||
TaskType,
|
||||
UserData,
|
||||
deserialize_task,
|
||||
)
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
DOMAIN as SENSOR_DOMAIN,
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.issue_registry import (
|
||||
IssueSeverity,
|
||||
async_create_issue,
|
||||
async_delete_issue,
|
||||
)
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from .const import ASSETS_URL
|
||||
from .const import ASSETS_URL, DOMAIN
|
||||
from .entity import HabiticaBase
|
||||
from .types import HabiticaConfigEntry
|
||||
from .util import get_attribute_points, get_attributes_total
|
||||
from .util import entity_used_in, get_attribute_points, get_attributes_total
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class HabiticaSensorEntityDescription(SensorEntityDescription):
|
||||
"""Habitica Sensor Description."""
|
||||
class HabitipySensorEntityDescription(SensorEntityDescription):
|
||||
"""Habitipy Sensor Description."""
|
||||
|
||||
value_fn: Callable[[UserData, ContentData], StateType]
|
||||
attributes_fn: Callable[[UserData, ContentData], dict[str, Any] | None] | None = (
|
||||
None
|
||||
)
|
||||
value_fn: Callable[[dict[str, Any], dict[str, Any]], StateType]
|
||||
attributes_fn: (
|
||||
Callable[[dict[str, Any], dict[str, Any]], dict[str, Any] | None] | None
|
||||
) = None
|
||||
entity_picture: str | None = None
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class HabiticaTaskSensorEntityDescription(SensorEntityDescription):
|
||||
"""Habitica Task Sensor Description."""
|
||||
class HabitipyTaskSensorEntityDescription(SensorEntityDescription):
|
||||
"""Habitipy Task Sensor Description."""
|
||||
|
||||
value_fn: Callable[[list[TaskData]], list[TaskData]]
|
||||
value_fn: Callable[[list[dict[str, Any]]], list[dict[str, Any]]]
|
||||
|
||||
|
||||
class HabiticaSensorEntity(StrEnum):
|
||||
"""Habitica Entities."""
|
||||
class HabitipySensorEntity(StrEnum):
|
||||
"""Habitipy Entities."""
|
||||
|
||||
DISPLAY_NAME = "display_name"
|
||||
HEALTH = "health"
|
||||
@@ -66,6 +64,8 @@ class HabiticaSensorEntity(StrEnum):
|
||||
GOLD = "gold"
|
||||
CLASS = "class"
|
||||
HABITS = "habits"
|
||||
DAILIES = "dailys"
|
||||
TODOS = "todos"
|
||||
REWARDS = "rewards"
|
||||
GEMS = "gems"
|
||||
TRINKETS = "trinkets"
|
||||
@@ -75,105 +75,110 @@ class HabiticaSensorEntity(StrEnum):
|
||||
PERCEPTION = "perception"
|
||||
|
||||
|
||||
SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = (
|
||||
HabiticaSensorEntityDescription(
|
||||
key=HabiticaSensorEntity.DISPLAY_NAME,
|
||||
translation_key=HabiticaSensorEntity.DISPLAY_NAME,
|
||||
value_fn=lambda user, _: user.profile.name,
|
||||
SENSOR_DESCRIPTIONS: tuple[HabitipySensorEntityDescription, ...] = (
|
||||
HabitipySensorEntityDescription(
|
||||
key=HabitipySensorEntity.DISPLAY_NAME,
|
||||
translation_key=HabitipySensorEntity.DISPLAY_NAME,
|
||||
value_fn=lambda user, _: user.get("profile", {}).get("name"),
|
||||
),
|
||||
HabiticaSensorEntityDescription(
|
||||
key=HabiticaSensorEntity.HEALTH,
|
||||
translation_key=HabiticaSensorEntity.HEALTH,
|
||||
HabitipySensorEntityDescription(
|
||||
key=HabitipySensorEntity.HEALTH,
|
||||
translation_key=HabitipySensorEntity.HEALTH,
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda user, _: user.stats.hp,
|
||||
value_fn=lambda user, _: user.get("stats", {}).get("hp"),
|
||||
),
|
||||
HabiticaSensorEntityDescription(
|
||||
key=HabiticaSensorEntity.HEALTH_MAX,
|
||||
translation_key=HabiticaSensorEntity.HEALTH_MAX,
|
||||
HabitipySensorEntityDescription(
|
||||
key=HabitipySensorEntity.HEALTH_MAX,
|
||||
translation_key=HabitipySensorEntity.HEALTH_MAX,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda user, _: 50,
|
||||
value_fn=lambda user, _: user.get("stats", {}).get("maxHealth"),
|
||||
),
|
||||
HabiticaSensorEntityDescription(
|
||||
key=HabiticaSensorEntity.MANA,
|
||||
translation_key=HabiticaSensorEntity.MANA,
|
||||
HabitipySensorEntityDescription(
|
||||
key=HabitipySensorEntity.MANA,
|
||||
translation_key=HabitipySensorEntity.MANA,
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda user, _: user.stats.mp,
|
||||
value_fn=lambda user, _: user.get("stats", {}).get("mp"),
|
||||
),
|
||||
HabiticaSensorEntityDescription(
|
||||
key=HabiticaSensorEntity.MANA_MAX,
|
||||
translation_key=HabiticaSensorEntity.MANA_MAX,
|
||||
value_fn=lambda user, _: user.stats.maxMP,
|
||||
HabitipySensorEntityDescription(
|
||||
key=HabitipySensorEntity.MANA_MAX,
|
||||
translation_key=HabitipySensorEntity.MANA_MAX,
|
||||
value_fn=lambda user, _: user.get("stats", {}).get("maxMP"),
|
||||
),
|
||||
HabiticaSensorEntityDescription(
|
||||
key=HabiticaSensorEntity.EXPERIENCE,
|
||||
translation_key=HabiticaSensorEntity.EXPERIENCE,
|
||||
value_fn=lambda user, _: user.stats.exp,
|
||||
HabitipySensorEntityDescription(
|
||||
key=HabitipySensorEntity.EXPERIENCE,
|
||||
translation_key=HabitipySensorEntity.EXPERIENCE,
|
||||
value_fn=lambda user, _: user.get("stats", {}).get("exp"),
|
||||
),
|
||||
HabiticaSensorEntityDescription(
|
||||
key=HabiticaSensorEntity.EXPERIENCE_MAX,
|
||||
translation_key=HabiticaSensorEntity.EXPERIENCE_MAX,
|
||||
value_fn=lambda user, _: user.stats.toNextLevel,
|
||||
HabitipySensorEntityDescription(
|
||||
key=HabitipySensorEntity.EXPERIENCE_MAX,
|
||||
translation_key=HabitipySensorEntity.EXPERIENCE_MAX,
|
||||
value_fn=lambda user, _: user.get("stats", {}).get("toNextLevel"),
|
||||
),
|
||||
HabiticaSensorEntityDescription(
|
||||
key=HabiticaSensorEntity.LEVEL,
|
||||
translation_key=HabiticaSensorEntity.LEVEL,
|
||||
value_fn=lambda user, _: user.stats.lvl,
|
||||
HabitipySensorEntityDescription(
|
||||
key=HabitipySensorEntity.LEVEL,
|
||||
translation_key=HabitipySensorEntity.LEVEL,
|
||||
value_fn=lambda user, _: user.get("stats", {}).get("lvl"),
|
||||
),
|
||||
HabiticaSensorEntityDescription(
|
||||
key=HabiticaSensorEntity.GOLD,
|
||||
translation_key=HabiticaSensorEntity.GOLD,
|
||||
HabitipySensorEntityDescription(
|
||||
key=HabitipySensorEntity.GOLD,
|
||||
translation_key=HabitipySensorEntity.GOLD,
|
||||
suggested_display_precision=2,
|
||||
value_fn=lambda user, _: user.stats.gp,
|
||||
value_fn=lambda user, _: user.get("stats", {}).get("gp"),
|
||||
),
|
||||
HabiticaSensorEntityDescription(
|
||||
key=HabiticaSensorEntity.CLASS,
|
||||
translation_key=HabiticaSensorEntity.CLASS,
|
||||
value_fn=lambda user, _: user.stats.Class.value if user.stats.Class else None,
|
||||
HabitipySensorEntityDescription(
|
||||
key=HabitipySensorEntity.CLASS,
|
||||
translation_key=HabitipySensorEntity.CLASS,
|
||||
value_fn=lambda user, _: user.get("stats", {}).get("class"),
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[item.value for item in HabiticaClass],
|
||||
options=["warrior", "healer", "wizard", "rogue"],
|
||||
),
|
||||
HabiticaSensorEntityDescription(
|
||||
key=HabiticaSensorEntity.GEMS,
|
||||
translation_key=HabiticaSensorEntity.GEMS,
|
||||
value_fn=lambda user, _: round(user.balance * 4) if user.balance else None,
|
||||
HabitipySensorEntityDescription(
|
||||
key=HabitipySensorEntity.GEMS,
|
||||
translation_key=HabitipySensorEntity.GEMS,
|
||||
value_fn=lambda user, _: user.get("balance", 0) * 4,
|
||||
suggested_display_precision=0,
|
||||
entity_picture="shop_gem.png",
|
||||
),
|
||||
HabiticaSensorEntityDescription(
|
||||
key=HabiticaSensorEntity.TRINKETS,
|
||||
translation_key=HabiticaSensorEntity.TRINKETS,
|
||||
value_fn=lambda user, _: user.purchased.plan.consecutive.trinkets or 0,
|
||||
HabitipySensorEntityDescription(
|
||||
key=HabitipySensorEntity.TRINKETS,
|
||||
translation_key=HabitipySensorEntity.TRINKETS,
|
||||
value_fn=(
|
||||
lambda user, _: user.get("purchased", {})
|
||||
.get("plan", {})
|
||||
.get("consecutive", {})
|
||||
.get("trinkets", 0)
|
||||
),
|
||||
suggested_display_precision=0,
|
||||
native_unit_of_measurement="⧖",
|
||||
entity_picture="notif_subscriber_reward.png",
|
||||
),
|
||||
HabiticaSensorEntityDescription(
|
||||
key=HabiticaSensorEntity.STRENGTH,
|
||||
translation_key=HabiticaSensorEntity.STRENGTH,
|
||||
value_fn=lambda user, content: get_attributes_total(user, content, "Str"),
|
||||
attributes_fn=lambda user, content: get_attribute_points(user, content, "Str"),
|
||||
HabitipySensorEntityDescription(
|
||||
key=HabitipySensorEntity.STRENGTH,
|
||||
translation_key=HabitipySensorEntity.STRENGTH,
|
||||
value_fn=lambda user, content: get_attributes_total(user, content, "str"),
|
||||
attributes_fn=lambda user, content: get_attribute_points(user, content, "str"),
|
||||
suggested_display_precision=0,
|
||||
native_unit_of_measurement="STR",
|
||||
),
|
||||
HabiticaSensorEntityDescription(
|
||||
key=HabiticaSensorEntity.INTELLIGENCE,
|
||||
translation_key=HabiticaSensorEntity.INTELLIGENCE,
|
||||
value_fn=lambda user, content: get_attributes_total(user, content, "Int"),
|
||||
attributes_fn=lambda user, content: get_attribute_points(user, content, "Int"),
|
||||
HabitipySensorEntityDescription(
|
||||
key=HabitipySensorEntity.INTELLIGENCE,
|
||||
translation_key=HabitipySensorEntity.INTELLIGENCE,
|
||||
value_fn=lambda user, content: get_attributes_total(user, content, "int"),
|
||||
attributes_fn=lambda user, content: get_attribute_points(user, content, "int"),
|
||||
suggested_display_precision=0,
|
||||
native_unit_of_measurement="INT",
|
||||
),
|
||||
HabiticaSensorEntityDescription(
|
||||
key=HabiticaSensorEntity.PERCEPTION,
|
||||
translation_key=HabiticaSensorEntity.PERCEPTION,
|
||||
HabitipySensorEntityDescription(
|
||||
key=HabitipySensorEntity.PERCEPTION,
|
||||
translation_key=HabitipySensorEntity.PERCEPTION,
|
||||
value_fn=lambda user, content: get_attributes_total(user, content, "per"),
|
||||
attributes_fn=lambda user, content: get_attribute_points(user, content, "per"),
|
||||
suggested_display_precision=0,
|
||||
native_unit_of_measurement="PER",
|
||||
),
|
||||
HabiticaSensorEntityDescription(
|
||||
key=HabiticaSensorEntity.CONSTITUTION,
|
||||
translation_key=HabiticaSensorEntity.CONSTITUTION,
|
||||
HabitipySensorEntityDescription(
|
||||
key=HabitipySensorEntity.CONSTITUTION,
|
||||
translation_key=HabitipySensorEntity.CONSTITUTION,
|
||||
value_fn=lambda user, content: get_attributes_total(user, content, "con"),
|
||||
attributes_fn=lambda user, content: get_attribute_points(user, content, "con"),
|
||||
suggested_display_precision=0,
|
||||
@@ -198,7 +203,7 @@ TASKS_MAP = {
|
||||
"yester_daily": "yesterDaily",
|
||||
"completed": "completed",
|
||||
"collapse_checklist": "collapseChecklist",
|
||||
"type": "Type",
|
||||
"type": "type",
|
||||
"notes": "notes",
|
||||
"tags": "tags",
|
||||
"value": "value",
|
||||
@@ -212,16 +217,30 @@ TASKS_MAP = {
|
||||
}
|
||||
|
||||
|
||||
TASK_SENSOR_DESCRIPTION: tuple[HabiticaTaskSensorEntityDescription, ...] = (
|
||||
HabiticaTaskSensorEntityDescription(
|
||||
key=HabiticaSensorEntity.HABITS,
|
||||
translation_key=HabiticaSensorEntity.HABITS,
|
||||
value_fn=lambda tasks: [r for r in tasks if r.Type is TaskType.HABIT],
|
||||
TASK_SENSOR_DESCRIPTION: tuple[HabitipyTaskSensorEntityDescription, ...] = (
|
||||
HabitipyTaskSensorEntityDescription(
|
||||
key=HabitipySensorEntity.HABITS,
|
||||
translation_key=HabitipySensorEntity.HABITS,
|
||||
value_fn=lambda tasks: [r for r in tasks if r.get("type") == "habit"],
|
||||
),
|
||||
HabiticaTaskSensorEntityDescription(
|
||||
key=HabiticaSensorEntity.REWARDS,
|
||||
translation_key=HabiticaSensorEntity.REWARDS,
|
||||
value_fn=lambda tasks: [r for r in tasks if r.Type is TaskType.REWARD],
|
||||
HabitipyTaskSensorEntityDescription(
|
||||
key=HabitipySensorEntity.DAILIES,
|
||||
translation_key=HabitipySensorEntity.DAILIES,
|
||||
value_fn=lambda tasks: [r for r in tasks if r.get("type") == "daily"],
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
HabitipyTaskSensorEntityDescription(
|
||||
key=HabitipySensorEntity.TODOS,
|
||||
translation_key=HabitipySensorEntity.TODOS,
|
||||
value_fn=lambda tasks: [
|
||||
r for r in tasks if r.get("type") == "todo" and not r.get("completed")
|
||||
],
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
HabitipyTaskSensorEntityDescription(
|
||||
key=HabitipySensorEntity.REWARDS,
|
||||
translation_key=HabitipySensorEntity.REWARDS,
|
||||
value_fn=lambda tasks: [r for r in tasks if r.get("type") == "reward"],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -236,19 +255,19 @@ async def async_setup_entry(
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
entities: list[SensorEntity] = [
|
||||
HabiticaSensor(coordinator, description) for description in SENSOR_DESCRIPTIONS
|
||||
HabitipySensor(coordinator, description) for description in SENSOR_DESCRIPTIONS
|
||||
]
|
||||
entities.extend(
|
||||
HabiticaTaskSensor(coordinator, description)
|
||||
HabitipyTaskSensor(coordinator, description)
|
||||
for description in TASK_SENSOR_DESCRIPTION
|
||||
)
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
class HabiticaSensor(HabiticaBase, SensorEntity):
|
||||
class HabitipySensor(HabiticaBase, SensorEntity):
|
||||
"""A generic Habitica sensor."""
|
||||
|
||||
entity_description: HabiticaSensorEntityDescription
|
||||
entity_description: HabitipySensorEntityDescription
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
@@ -273,10 +292,10 @@ class HabiticaSensor(HabiticaBase, SensorEntity):
|
||||
return None
|
||||
|
||||
|
||||
class HabiticaTaskSensor(HabiticaBase, SensorEntity):
|
||||
class HabitipyTaskSensor(HabiticaBase, SensorEntity):
|
||||
"""A Habitica task sensor."""
|
||||
|
||||
entity_description: HabiticaTaskSensorEntityDescription
|
||||
entity_description: HabitipyTaskSensorEntityDescription
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
@@ -290,12 +309,47 @@ class HabiticaTaskSensor(HabiticaBase, SensorEntity):
|
||||
attrs = {}
|
||||
|
||||
# Map tasks to TASKS_MAP
|
||||
for task_data in self.entity_description.value_fn(self.coordinator.data.tasks):
|
||||
received_task = deserialize_task(asdict(task_data))
|
||||
for received_task in self.entity_description.value_fn(
|
||||
self.coordinator.data.tasks
|
||||
):
|
||||
task_id = received_task[TASKS_MAP_ID]
|
||||
task = {}
|
||||
for map_key, map_value in TASKS_MAP.items():
|
||||
if value := received_task.get(map_value):
|
||||
task[map_key] = value
|
||||
attrs[str(task_id)] = task
|
||||
attrs[task_id] = task
|
||||
return attrs
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Raise issue when entity is registered and was not disabled."""
|
||||
if TYPE_CHECKING:
|
||||
assert self.unique_id
|
||||
if entity_id := er.async_get(self.hass).async_get_entity_id(
|
||||
SENSOR_DOMAIN, DOMAIN, self.unique_id
|
||||
):
|
||||
if (
|
||||
self.enabled
|
||||
and self.entity_description.key
|
||||
in (HabitipySensorEntity.TODOS, HabitipySensorEntity.DAILIES)
|
||||
and entity_used_in(self.hass, entity_id)
|
||||
):
|
||||
async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"deprecated_task_entity_{self.entity_description.key}",
|
||||
breaks_in_ha_version="2025.2.0",
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_task_entity",
|
||||
translation_placeholders={
|
||||
"task_name": str(self.name),
|
||||
"entity": entity_id,
|
||||
},
|
||||
)
|
||||
else:
|
||||
async_delete_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"deprecated_task_entity_{self.entity_description.key}",
|
||||
)
|
||||
await super().async_added_to_hass()
|
||||
|
||||
@@ -2,22 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientError
|
||||
from habiticalib import (
|
||||
Direction,
|
||||
HabiticaException,
|
||||
NotAuthorizedError,
|
||||
NotFoundError,
|
||||
Skill,
|
||||
TaskData,
|
||||
TaskPriority,
|
||||
TaskType,
|
||||
TooManyRequestsError,
|
||||
)
|
||||
from aiohttp import ClientResponseError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
@@ -39,14 +28,10 @@ from .const import (
|
||||
ATTR_DATA,
|
||||
ATTR_DIRECTION,
|
||||
ATTR_ITEM,
|
||||
ATTR_KEYWORD,
|
||||
ATTR_PATH,
|
||||
ATTR_PRIORITY,
|
||||
ATTR_SKILL,
|
||||
ATTR_TAG,
|
||||
ATTR_TARGET,
|
||||
ATTR_TASK,
|
||||
ATTR_TYPE,
|
||||
DOMAIN,
|
||||
EVENT_API_CALL_SUCCESS,
|
||||
SERVICE_ABORT_QUEST,
|
||||
@@ -54,7 +39,6 @@ from .const import (
|
||||
SERVICE_API_CALL,
|
||||
SERVICE_CANCEL_QUEST,
|
||||
SERVICE_CAST_SKILL,
|
||||
SERVICE_GET_TASKS,
|
||||
SERVICE_LEAVE_QUEST,
|
||||
SERVICE_REJECT_QUEST,
|
||||
SERVICE_SCORE_HABIT,
|
||||
@@ -104,40 +88,6 @@ SERVICE_TRANSFORMATION_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
SERVICE_GET_TASKS_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(),
|
||||
vol.Optional(ATTR_TYPE): vol.All(
|
||||
cv.ensure_list, [vol.All(vol.Upper, vol.In({x.name for x in TaskType}))]
|
||||
),
|
||||
vol.Optional(ATTR_PRIORITY): vol.All(
|
||||
cv.ensure_list, [vol.All(vol.Upper, vol.In({x.name for x in TaskPriority}))]
|
||||
),
|
||||
vol.Optional(ATTR_TASK): vol.All(cv.ensure_list, [str]),
|
||||
vol.Optional(ATTR_TAG): vol.All(cv.ensure_list, [str]),
|
||||
vol.Optional(ATTR_KEYWORD): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
SKILL_MAP = {
|
||||
"pickpocket": Skill.PICKPOCKET,
|
||||
"backstab": Skill.BACKSTAB,
|
||||
"smash": Skill.BRUTAL_SMASH,
|
||||
"fireball": Skill.BURST_OF_FLAMES,
|
||||
}
|
||||
COST_MAP = {
|
||||
"pickpocket": "10 MP",
|
||||
"backstab": "15 MP",
|
||||
"smash": "10 MP",
|
||||
"fireball": "10 MP",
|
||||
}
|
||||
ITEMID_MAP = {
|
||||
"snowball": Skill.SNOWBALL,
|
||||
"spooky_sparkles": Skill.SPOOKY_SPARKLES,
|
||||
"seafoam": Skill.SEAFOAM,
|
||||
"shiny_seed": Skill.SHINY_SEED,
|
||||
}
|
||||
|
||||
|
||||
def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry:
|
||||
"""Return config entry or raise if not found or not loaded."""
|
||||
@@ -173,12 +123,12 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
|
||||
|
||||
name = call.data[ATTR_NAME]
|
||||
path = call.data[ATTR_PATH]
|
||||
entries: list[HabiticaConfigEntry] = hass.config_entries.async_entries(DOMAIN)
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
|
||||
api = None
|
||||
for entry in entries:
|
||||
if entry.data[CONF_NAME] == name:
|
||||
api = await entry.runtime_data.habitica.habitipy()
|
||||
api = entry.runtime_data.api
|
||||
break
|
||||
if api is None:
|
||||
_LOGGER.error("API_CALL: User '%s' not configured", name)
|
||||
@@ -201,15 +151,18 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
|
||||
"""Skill action."""
|
||||
entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
skill = SKILL_MAP[call.data[ATTR_SKILL]]
|
||||
cost = COST_MAP[call.data[ATTR_SKILL]]
|
||||
|
||||
skill = {
|
||||
"pickpocket": {"spellId": "pickPocket", "cost": "10 MP"},
|
||||
"backstab": {"spellId": "backStab", "cost": "15 MP"},
|
||||
"smash": {"spellId": "smash", "cost": "10 MP"},
|
||||
"fireball": {"spellId": "fireball", "cost": "10 MP"},
|
||||
}
|
||||
try:
|
||||
task_id = next(
|
||||
task.id
|
||||
task["id"]
|
||||
for task in coordinator.data.tasks
|
||||
if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text)
|
||||
if call.data[ATTR_TASK] in (task["id"], task.get("alias"))
|
||||
or call.data[ATTR_TASK] == task["text"]
|
||||
)
|
||||
except StopIteration as e:
|
||||
raise ServiceValidationError(
|
||||
@@ -219,76 +172,75 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
|
||||
) from e
|
||||
|
||||
try:
|
||||
response = await coordinator.habitica.cast_skill(skill, task_id)
|
||||
except TooManyRequestsError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_rate_limit_exception",
|
||||
) from e
|
||||
except NotAuthorizedError as e:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="not_enough_mana",
|
||||
translation_placeholders={
|
||||
"cost": cost,
|
||||
"mana": f"{int(coordinator.data.user.stats.mp or 0)} MP",
|
||||
},
|
||||
) from e
|
||||
except NotFoundError as e:
|
||||
# could also be task not found, but the task is looked up
|
||||
# before the request, so most likely wrong skill selected
|
||||
# or the skill hasn't been unlocked yet.
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="skill_not_found",
|
||||
translation_placeholders={"skill": call.data[ATTR_SKILL]},
|
||||
) from e
|
||||
except (HabiticaException, ClientError) as e:
|
||||
response: dict[str, Any] = await coordinator.api.user.class_.cast[
|
||||
skill[call.data[ATTR_SKILL]]["spellId"]
|
||||
].post(targetId=task_id)
|
||||
except ClientResponseError as e:
|
||||
if e.status == HTTPStatus.TOO_MANY_REQUESTS:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_rate_limit_exception",
|
||||
) from e
|
||||
if e.status == HTTPStatus.UNAUTHORIZED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="not_enough_mana",
|
||||
translation_placeholders={
|
||||
"cost": skill[call.data[ATTR_SKILL]]["cost"],
|
||||
"mana": f"{int(coordinator.data.user.get("stats", {}).get("mp", 0))} MP",
|
||||
},
|
||||
) from e
|
||||
if e.status == HTTPStatus.NOT_FOUND:
|
||||
# could also be task not found, but the task is looked up
|
||||
# before the request, so most likely wrong skill selected
|
||||
# or the skill hasn't been unlocked yet.
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="skill_not_found",
|
||||
translation_placeholders={"skill": call.data[ATTR_SKILL]},
|
||||
) from e
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_call_exception",
|
||||
) from e
|
||||
else:
|
||||
await coordinator.async_request_refresh()
|
||||
return asdict(response.data)
|
||||
return response
|
||||
|
||||
async def manage_quests(call: ServiceCall) -> ServiceResponse:
|
||||
"""Accept, reject, start, leave or cancel quests."""
|
||||
entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
FUNC_MAP = {
|
||||
SERVICE_ABORT_QUEST: coordinator.habitica.abort_quest,
|
||||
SERVICE_ACCEPT_QUEST: coordinator.habitica.accept_quest,
|
||||
SERVICE_CANCEL_QUEST: coordinator.habitica.cancel_quest,
|
||||
SERVICE_LEAVE_QUEST: coordinator.habitica.leave_quest,
|
||||
SERVICE_REJECT_QUEST: coordinator.habitica.reject_quest,
|
||||
SERVICE_START_QUEST: coordinator.habitica.start_quest,
|
||||
COMMAND_MAP = {
|
||||
SERVICE_ABORT_QUEST: "abort",
|
||||
SERVICE_ACCEPT_QUEST: "accept",
|
||||
SERVICE_CANCEL_QUEST: "cancel",
|
||||
SERVICE_LEAVE_QUEST: "leave",
|
||||
SERVICE_REJECT_QUEST: "reject",
|
||||
SERVICE_START_QUEST: "force-start",
|
||||
}
|
||||
|
||||
func = FUNC_MAP[call.service]
|
||||
|
||||
try:
|
||||
response = await func()
|
||||
except TooManyRequestsError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_rate_limit_exception",
|
||||
) from e
|
||||
except NotAuthorizedError as e:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN, translation_key="quest_action_unallowed"
|
||||
) from e
|
||||
except NotFoundError as e:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN, translation_key="quest_not_found"
|
||||
) from e
|
||||
except (HabiticaException, ClientError) as e:
|
||||
return await coordinator.api.groups.party.quests[
|
||||
COMMAND_MAP[call.service]
|
||||
].post()
|
||||
except ClientResponseError as e:
|
||||
if e.status == HTTPStatus.TOO_MANY_REQUESTS:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_rate_limit_exception",
|
||||
) from e
|
||||
if e.status == HTTPStatus.UNAUTHORIZED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN, translation_key="quest_action_unallowed"
|
||||
) from e
|
||||
if e.status == HTTPStatus.NOT_FOUND:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN, translation_key="quest_not_found"
|
||||
) from e
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="service_call_exception"
|
||||
) from e
|
||||
else:
|
||||
return asdict(response.data)
|
||||
|
||||
for service in (
|
||||
SERVICE_ABORT_QUEST,
|
||||
@@ -310,15 +262,12 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
|
||||
"""Score a task action."""
|
||||
entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
direction = (
|
||||
Direction.DOWN if call.data.get(ATTR_DIRECTION) == "down" else Direction.UP
|
||||
)
|
||||
try:
|
||||
task_id, task_value = next(
|
||||
(task.id, task.value)
|
||||
(task["id"], task.get("value"))
|
||||
for task in coordinator.data.tasks
|
||||
if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text)
|
||||
if call.data[ATTR_TASK] in (task["id"], task.get("alias"))
|
||||
or call.data[ATTR_TASK] == task["text"]
|
||||
)
|
||||
except StopIteration as e:
|
||||
raise ServiceValidationError(
|
||||
@@ -327,76 +276,81 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
|
||||
translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"},
|
||||
) from e
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert task_id
|
||||
try:
|
||||
response = await coordinator.habitica.update_score(task_id, direction)
|
||||
except TooManyRequestsError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_rate_limit_exception",
|
||||
) from e
|
||||
except NotAuthorizedError as e:
|
||||
if task_value is not None:
|
||||
response: dict[str, Any] = (
|
||||
await coordinator.api.tasks[task_id]
|
||||
.score[call.data.get(ATTR_DIRECTION, "up")]
|
||||
.post()
|
||||
)
|
||||
except ClientResponseError as e:
|
||||
if e.status == HTTPStatus.TOO_MANY_REQUESTS:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_rate_limit_exception",
|
||||
) from e
|
||||
if e.status == HTTPStatus.UNAUTHORIZED and task_value is not None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="not_enough_gold",
|
||||
translation_placeholders={
|
||||
"gold": f"{(coordinator.data.user.stats.gp or 0):.2f} GP",
|
||||
"cost": f"{task_value:.2f} GP",
|
||||
"gold": f"{coordinator.data.user["stats"]["gp"]:.2f} GP",
|
||||
"cost": f"{task_value} GP",
|
||||
},
|
||||
) from e
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_call_exception",
|
||||
) from e
|
||||
except (HabiticaException, ClientError) as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_call_exception",
|
||||
) from e
|
||||
else:
|
||||
await coordinator.async_request_refresh()
|
||||
return asdict(response.data)
|
||||
return response
|
||||
|
||||
async def transformation(call: ServiceCall) -> ServiceResponse:
|
||||
"""User a transformation item on a player character."""
|
||||
|
||||
entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
item = ITEMID_MAP[call.data[ATTR_ITEM]]
|
||||
ITEMID_MAP = {
|
||||
"snowball": {"itemId": "snowball"},
|
||||
"spooky_sparkles": {"itemId": "spookySparkles"},
|
||||
"seafoam": {"itemId": "seafoam"},
|
||||
"shiny_seed": {"itemId": "shinySeed"},
|
||||
}
|
||||
# check if target is self
|
||||
if call.data[ATTR_TARGET] in (
|
||||
str(coordinator.data.user.id),
|
||||
coordinator.data.user.profile.name,
|
||||
coordinator.data.user.auth.local.username,
|
||||
coordinator.data.user["id"],
|
||||
coordinator.data.user["profile"]["name"],
|
||||
coordinator.data.user["auth"]["local"]["username"],
|
||||
):
|
||||
target_id = coordinator.data.user.id
|
||||
target_id = coordinator.data.user["id"]
|
||||
else:
|
||||
# check if target is a party member
|
||||
try:
|
||||
party = await coordinator.habitica.get_group_members(public_fields=True)
|
||||
except NotFoundError as e:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="party_not_found",
|
||||
) from e
|
||||
except (ClientError, HabiticaException) as e:
|
||||
party = await coordinator.api.groups.party.members.get()
|
||||
except ClientResponseError as e:
|
||||
if e.status == HTTPStatus.TOO_MANY_REQUESTS:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_rate_limit_exception",
|
||||
) from e
|
||||
if e.status == HTTPStatus.NOT_FOUND:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="party_not_found",
|
||||
) from e
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_call_exception",
|
||||
) from e
|
||||
try:
|
||||
target_id = next(
|
||||
member.id
|
||||
for member in party.data
|
||||
if member.id
|
||||
and call.data[ATTR_TARGET].lower()
|
||||
member["id"]
|
||||
for member in party
|
||||
if call.data[ATTR_TARGET].lower()
|
||||
in (
|
||||
str(member.id),
|
||||
str(member.auth.local.username).lower(),
|
||||
str(member.profile.name).lower(),
|
||||
member["id"],
|
||||
member["auth"]["local"]["username"].lower(),
|
||||
member["profile"]["name"].lower(),
|
||||
)
|
||||
)
|
||||
except StopIteration as e:
|
||||
@@ -406,71 +360,27 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
|
||||
translation_placeholders={"target": f"'{call.data[ATTR_TARGET]}'"},
|
||||
) from e
|
||||
try:
|
||||
response = await coordinator.habitica.cast_skill(item, target_id)
|
||||
except TooManyRequestsError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_rate_limit_exception",
|
||||
) from e
|
||||
except NotAuthorizedError as e:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="item_not_found",
|
||||
translation_placeholders={"item": call.data[ATTR_ITEM]},
|
||||
) from e
|
||||
except (HabiticaException, ClientError) as e:
|
||||
response: dict[str, Any] = await coordinator.api.user.class_.cast[
|
||||
ITEMID_MAP[call.data[ATTR_ITEM]]["itemId"]
|
||||
].post(targetId=target_id)
|
||||
except ClientResponseError as e:
|
||||
if e.status == HTTPStatus.TOO_MANY_REQUESTS:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_rate_limit_exception",
|
||||
) from e
|
||||
if e.status == HTTPStatus.UNAUTHORIZED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="item_not_found",
|
||||
translation_placeholders={"item": call.data[ATTR_ITEM]},
|
||||
) from e
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_call_exception",
|
||||
) from e
|
||||
else:
|
||||
return asdict(response.data)
|
||||
|
||||
async def get_tasks(call: ServiceCall) -> ServiceResponse:
|
||||
"""Get tasks action."""
|
||||
|
||||
entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
|
||||
coordinator = entry.runtime_data
|
||||
response: list[TaskData] = coordinator.data.tasks
|
||||
|
||||
if types := {TaskType[x] for x in call.data.get(ATTR_TYPE, [])}:
|
||||
response = [task for task in response if task.Type in types]
|
||||
|
||||
if priority := {TaskPriority[x] for x in call.data.get(ATTR_PRIORITY, [])}:
|
||||
response = [task for task in response if task.priority in priority]
|
||||
|
||||
if tasks := call.data.get(ATTR_TASK):
|
||||
response = [
|
||||
task
|
||||
for task in response
|
||||
if str(task.id) in tasks or task.alias in tasks or task.text in tasks
|
||||
]
|
||||
|
||||
if tags := call.data.get(ATTR_TAG):
|
||||
tag_ids = {
|
||||
tag.id
|
||||
for tag in coordinator.data.user.tags
|
||||
if (tag.name and tag.name.lower())
|
||||
in (tag.lower() for tag in tags) # Case-insensitive matching
|
||||
and tag.id
|
||||
}
|
||||
|
||||
response = [
|
||||
task
|
||||
for task in response
|
||||
if any(tag_id in task.tags for tag_id in tag_ids if task.tags)
|
||||
]
|
||||
if keyword := call.data.get(ATTR_KEYWORD):
|
||||
keyword = keyword.lower()
|
||||
response = [
|
||||
task
|
||||
for task in response
|
||||
if (task.text and keyword in task.text.lower())
|
||||
or (task.notes and keyword in task.notes.lower())
|
||||
or any(keyword in item.text.lower() for item in task.checklist)
|
||||
]
|
||||
result: dict[str, Any] = {"tasks": response}
|
||||
return result
|
||||
return response
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
@@ -509,10 +419,3 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
|
||||
schema=SERVICE_TRANSFORMATION_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_GET_TASKS,
|
||||
get_tasks,
|
||||
schema=SERVICE_GET_TASKS_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
@@ -94,49 +94,3 @@ transformation:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
get_tasks:
|
||||
fields:
|
||||
config_entry: *config_entry
|
||||
filter:
|
||||
collapsed: true
|
||||
fields:
|
||||
type:
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- "habit"
|
||||
- "daily"
|
||||
- "todo"
|
||||
- "reward"
|
||||
mode: dropdown
|
||||
translation_key: "type"
|
||||
multiple: true
|
||||
sort: true
|
||||
priority:
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- "trivial"
|
||||
- "easy"
|
||||
- "medium"
|
||||
- "hard"
|
||||
mode: dropdown
|
||||
translation_key: "priority"
|
||||
multiple: true
|
||||
sort: false
|
||||
task:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
multiple: true
|
||||
tag:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
multiple: true
|
||||
keyword:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"todos": "To-Do's",
|
||||
"dailies": "Dailies",
|
||||
"config_entry_name": "Select character",
|
||||
"task_name": "Task name",
|
||||
"unit_tasks": "tasks",
|
||||
"unit_health_points": "HP",
|
||||
"unit_mana_points": "MP",
|
||||
@@ -11,15 +10,12 @@
|
||||
},
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"unique_id_mismatch": "Hmm, those login details are correct, but they're not for this adventurer. Got another account to try?",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"invalid_credentials": "Input is incomplete. You must provide either your login details or an API token"
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
@@ -53,38 +49,9 @@
|
||||
"data_description": {
|
||||
"url": "URL of the Habitica installation to connect to. Defaults to `{default_url}`",
|
||||
"api_user": "User ID of your Habitica account",
|
||||
"api_key": "API Token of the Habitica account",
|
||||
"verify_ssl": "Enable SSL certificate verification for secure connections. Disable only if connecting to a Habitica instance using a self-signed certificate"
|
||||
"api_key": "API Token of the Habitica account"
|
||||
},
|
||||
"description": "You can retrieve your `User ID` and `API Token` from [**Settings -> Site Data**]({site_data}) on Habitica or the instance you want to connect to"
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "Re-authorize {name} with Habitica",
|
||||
"description": " It seems your API token for **{name}** has been reset. To re-authorize the integration, you can either log in with your username or email, and password, or directly provide your new API token.",
|
||||
"sections": {
|
||||
"reauth_login": {
|
||||
"name": "Re-authorize via login",
|
||||
"description": "Enter your login details below to re-authorize the Home Assistant integration with Habitica",
|
||||
"data": {
|
||||
"username": "[%key:component::habitica::config::step::login::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"username": "[%key:component::habitica::config::step::login::data_description::username%]",
|
||||
"password": "[%key:component::habitica::config::step::login::data_description::password%]"
|
||||
}
|
||||
},
|
||||
"reauth_api_key": {
|
||||
"description": "Enter your new API token below. You can find it in Habitica under 'Settings -> Site Data'",
|
||||
"name": "Re-authorize via API Token",
|
||||
"data": {
|
||||
"api_key": "[%key:component::habitica::config::step::advanced::data::api_key%]"
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "[%key:component::habitica::config::step::advanced::data_description::api_key%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -167,11 +134,6 @@
|
||||
"name": "Daily reminders"
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
"avatar": {
|
||||
"name": "Avatar"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"display_name": {
|
||||
"name": "Display name"
|
||||
@@ -223,6 +185,14 @@
|
||||
"rogue": "Rogue"
|
||||
}
|
||||
},
|
||||
"todos": {
|
||||
"name": "[%key:component::habitica::common::todos%]",
|
||||
"unit_of_measurement": "[%key:component::habitica::common::unit_tasks%]"
|
||||
},
|
||||
"dailys": {
|
||||
"name": "[%key:component::habitica::common::dailies%]",
|
||||
"unit_of_measurement": "[%key:component::habitica::common::unit_tasks%]"
|
||||
},
|
||||
"habits": {
|
||||
"name": "Habits",
|
||||
"unit_of_measurement": "[%key:component::habitica::common::unit_tasks%]"
|
||||
@@ -395,12 +365,13 @@
|
||||
},
|
||||
"item_not_found": {
|
||||
"message": "Unable to use {item}, you don't own this item."
|
||||
},
|
||||
"authentication_failed": {
|
||||
"message": "Authentication failed. It looks like your API token has been reset. Please re-authenticate using your new token"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_task_entity": {
|
||||
"title": "The Habitica {task_name} sensor is deprecated",
|
||||
"description": "The Habitica entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your automations and scripts to replace the sensor entity with the newly added todo entity.\nWhen you are done migrating you can disable `{entity}`."
|
||||
},
|
||||
"deprecated_api_call": {
|
||||
"title": "The Habitica action habitica.api_call is deprecated",
|
||||
"description": "The Habitica action `habitica.api_call` is deprecated and will be removed in Home Assistant 2025.5.0.\n\nPlease update your automations and scripts to use other Habitica actions and entities."
|
||||
@@ -427,7 +398,7 @@
|
||||
},
|
||||
"cast_skill": {
|
||||
"name": "Cast a skill",
|
||||
"description": "Uses a skill or spell from your Habitica character on a specific task to affect its progress or status.",
|
||||
"description": "Use a skill or spell from your Habitica character on a specific task to affect its progress or status.",
|
||||
"fields": {
|
||||
"config_entry": {
|
||||
"name": "[%key:component::habitica::common::config_entry_name%]",
|
||||
@@ -438,14 +409,14 @@
|
||||
"description": "Select the skill or spell you want to cast on the task. Only skills corresponding to your character's class can be used."
|
||||
},
|
||||
"task": {
|
||||
"name": "[%key:component::habitica::common::task_name%]",
|
||||
"name": "Task name",
|
||||
"description": "The name (or task ID) of the task you want to target with the skill or spell."
|
||||
}
|
||||
}
|
||||
},
|
||||
"accept_quest": {
|
||||
"name": "Accept a quest invitation",
|
||||
"description": "Accepts a pending invitation to a quest.",
|
||||
"description": "Accept a pending invitation to a quest.",
|
||||
"fields": {
|
||||
"config_entry": {
|
||||
"name": "[%key:component::habitica::common::config_entry_name%]",
|
||||
@@ -455,7 +426,7 @@
|
||||
},
|
||||
"reject_quest": {
|
||||
"name": "Reject a quest invitation",
|
||||
"description": "Rejects a pending invitation to a quest.",
|
||||
"description": "Reject a pending invitation to a quest.",
|
||||
"fields": {
|
||||
"config_entry": {
|
||||
"name": "[%key:component::habitica::common::config_entry_name%]",
|
||||
@@ -465,7 +436,7 @@
|
||||
},
|
||||
"leave_quest": {
|
||||
"name": "Leave a quest",
|
||||
"description": "Leaves the current quest you are participating in.",
|
||||
"description": "Leave the current quest you are participating in.",
|
||||
"fields": {
|
||||
"config_entry": {
|
||||
"name": "[%key:component::habitica::common::config_entry_name%]",
|
||||
@@ -475,7 +446,7 @@
|
||||
},
|
||||
"abort_quest": {
|
||||
"name": "Abort an active quest",
|
||||
"description": "Terminates your party's ongoing quest. All progress will be lost and the quest roll returned to the owner's inventory. Only quest leader or group leader can perform this action.",
|
||||
"description": "Terminate your party's ongoing quest. All progress will be lost and the quest roll returned to the owner's inventory. Only quest leader or group leader can perform this action.",
|
||||
"fields": {
|
||||
"config_entry": {
|
||||
"name": "[%key:component::habitica::common::config_entry_name%]",
|
||||
@@ -485,7 +456,7 @@
|
||||
},
|
||||
"cancel_quest": {
|
||||
"name": "Cancel a pending quest",
|
||||
"description": "Cancels a quest that has not yet startet. All accepted and pending invitations will be canceled and the quest roll returned to the owner's inventory. Only quest leader or group leader can perform this action.",
|
||||
"description": "Cancel a quest that has not yet startet. All accepted and pending invitations will be canceled and the quest roll returned to the owner's inventory. Only quest leader or group leader can perform this action.",
|
||||
"fields": {
|
||||
"config_entry": {
|
||||
"name": "[%key:component::habitica::common::config_entry_name%]",
|
||||
@@ -495,7 +466,7 @@
|
||||
},
|
||||
"start_quest": {
|
||||
"name": "Force-start a pending quest",
|
||||
"description": "Begins the quest immediately, bypassing any pending invitations that haven't been accepted or rejected. Only quest leader or group leader can perform this action.",
|
||||
"description": "Begin the quest immediately, bypassing any pending invitations that haven't been accepted or rejected. Only quest leader or group leader can perform this action.",
|
||||
"fields": {
|
||||
"config_entry": {
|
||||
"name": "[%key:component::habitica::common::config_entry_name%]",
|
||||
@@ -505,7 +476,7 @@
|
||||
},
|
||||
"score_habit": {
|
||||
"name": "Track a habit",
|
||||
"description": "Increases the positive or negative streak of a habit to track its progress.",
|
||||
"description": "Increase the positive or negative streak of a habit to track its progress.",
|
||||
"fields": {
|
||||
"config_entry": {
|
||||
"name": "[%key:component::habitica::common::config_entry_name%]",
|
||||
@@ -523,7 +494,7 @@
|
||||
},
|
||||
"score_reward": {
|
||||
"name": "Buy a reward",
|
||||
"description": "Buys one of your custom rewards with gold earned by fulfilling tasks.",
|
||||
"description": "Reward yourself and buy one of your custom rewards with gold earned by fulfilling tasks.",
|
||||
"fields": {
|
||||
"config_entry": {
|
||||
"name": "[%key:component::habitica::common::config_entry_name%]",
|
||||
@@ -537,7 +508,7 @@
|
||||
},
|
||||
"transformation": {
|
||||
"name": "Use a transformation item",
|
||||
"description": "Uses a transformation item from your Habitica character's inventory on a member of your party or yourself.",
|
||||
"description": "Use a transformation item from your Habitica character's inventory on a member of your party or yourself.",
|
||||
"fields": {
|
||||
"config_entry": {
|
||||
"name": "Select character",
|
||||
@@ -552,42 +523,6 @@
|
||||
"description": "The name of the character you want to use the transformation item on. You can also specify the players username or user ID."
|
||||
}
|
||||
}
|
||||
},
|
||||
"get_tasks": {
|
||||
"name": "Get tasks",
|
||||
"description": "Retrieves tasks from your Habitica character.",
|
||||
"fields": {
|
||||
"config_entry": {
|
||||
"name": "[%key:component::habitica::common::config_entry_name%]",
|
||||
"description": "Choose the Habitica character to retrieve tasks from."
|
||||
},
|
||||
"type": {
|
||||
"name": "Task type",
|
||||
"description": "Filter tasks by type."
|
||||
},
|
||||
"priority": {
|
||||
"name": "Difficulty",
|
||||
"description": "Filter tasks by difficulty."
|
||||
},
|
||||
"task": {
|
||||
"name": "[%key:component::habitica::common::task_name%]",
|
||||
"description": "Select tasks by matching their name (or task ID)."
|
||||
},
|
||||
"tag": {
|
||||
"name": "Tag",
|
||||
"description": "Filter tasks that have one or more of the selected tags."
|
||||
},
|
||||
"keyword": {
|
||||
"name": "Keyword",
|
||||
"description": "Filter tasks by keyword, searching across titles, notes, and checklists."
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"filter": {
|
||||
"name": "Filter options",
|
||||
"description": "Use the optional filters to narrow the returned tasks."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
@@ -606,22 +541,6 @@
|
||||
"seafoam": "Seafoam",
|
||||
"shiny_seed": "Shiny seed"
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"options": {
|
||||
"daily": "Daily",
|
||||
"habit": "Habit",
|
||||
"todo": "To-do",
|
||||
"reward": "Reward"
|
||||
}
|
||||
},
|
||||
"priority": {
|
||||
"options": {
|
||||
"trivial": "Trivial",
|
||||
"easy": "Easy",
|
||||
"medium": "Medium",
|
||||
"hard": "Hard"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ class HabiticaSwitchEntityDescription(SwitchEntityDescription):
|
||||
|
||||
turn_on_fn: Callable[[HabiticaDataUpdateCoordinator], Any]
|
||||
turn_off_fn: Callable[[HabiticaDataUpdateCoordinator], Any]
|
||||
is_on_fn: Callable[[HabiticaData], bool | None]
|
||||
is_on_fn: Callable[[HabiticaData], bool]
|
||||
|
||||
|
||||
class HabiticaSwitchEntity(StrEnum):
|
||||
@@ -42,9 +42,9 @@ SWTICH_DESCRIPTIONS: tuple[HabiticaSwitchEntityDescription, ...] = (
|
||||
key=HabiticaSwitchEntity.SLEEP,
|
||||
translation_key=HabiticaSwitchEntity.SLEEP,
|
||||
device_class=SwitchDeviceClass.SWITCH,
|
||||
turn_on_fn=lambda coordinator: coordinator.habitica.toggle_sleep(),
|
||||
turn_off_fn=lambda coordinator: coordinator.habitica.toggle_sleep(),
|
||||
is_on_fn=lambda data: data.user.preferences.sleep,
|
||||
turn_on_fn=lambda coordinator: coordinator.api["user"]["sleep"].post(),
|
||||
turn_off_fn=lambda coordinator: coordinator.api["user"]["sleep"].post(),
|
||||
is_on_fn=lambda data: data.user["preferences"]["sleep"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from enum import StrEnum
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
from aiohttp import ClientError
|
||||
from habiticalib import Direction, HabiticaException, Task, TaskType
|
||||
from aiohttp import ClientResponseError
|
||||
|
||||
from homeassistant.components import persistent_notification
|
||||
from homeassistant.components.todo import (
|
||||
@@ -25,7 +24,7 @@ from homeassistant.util import dt as dt_util
|
||||
from .const import ASSETS_URL, DOMAIN
|
||||
from .coordinator import HabiticaDataUpdateCoordinator
|
||||
from .entity import HabiticaBase
|
||||
from .types import HabiticaConfigEntry
|
||||
from .types import HabiticaConfigEntry, HabiticaTaskType
|
||||
from .util import next_due_date
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
@@ -71,8 +70,8 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
|
||||
"""Delete Habitica tasks."""
|
||||
if len(uids) > 1 and self.entity_description.key is HabiticaTodoList.TODOS:
|
||||
try:
|
||||
await self.coordinator.habitica.delete_completed_todos()
|
||||
except (HabiticaException, ClientError) as e:
|
||||
await self.coordinator.api.tasks.clearCompletedTodos.post()
|
||||
except ClientResponseError as e:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="delete_completed_todos_failed",
|
||||
@@ -80,8 +79,8 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
|
||||
else:
|
||||
for task_id in uids:
|
||||
try:
|
||||
await self.coordinator.habitica.delete_task(UUID(task_id))
|
||||
except (HabiticaException, ClientError) as e:
|
||||
await self.coordinator.api.tasks[task_id].delete()
|
||||
except ClientResponseError as e:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key=f"delete_{self.entity_description.key}_failed",
|
||||
@@ -107,8 +106,9 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
|
||||
pos = 0
|
||||
|
||||
try:
|
||||
await self.coordinator.habitica.reorder_task(UUID(uid), pos)
|
||||
except (HabiticaException, ClientError) as e:
|
||||
await self.coordinator.api.tasks[uid].move.to[str(pos)].post()
|
||||
|
||||
except ClientResponseError as e:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key=f"move_{self.entity_description.key}_item_failed",
|
||||
@@ -118,14 +118,12 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
|
||||
# move tasks in the coordinator until we have fresh data
|
||||
tasks = self.coordinator.data.tasks
|
||||
new_pos = (
|
||||
tasks.index(
|
||||
next(task for task in tasks if task.id == UUID(previous_uid))
|
||||
)
|
||||
tasks.index(next(task for task in tasks if task["id"] == previous_uid))
|
||||
+ 1
|
||||
if previous_uid
|
||||
else 0
|
||||
)
|
||||
old_pos = tasks.index(next(task for task in tasks if task.id == UUID(uid)))
|
||||
old_pos = tasks.index(next(task for task in tasks if task["id"] == uid))
|
||||
tasks.insert(new_pos, tasks.pop(old_pos))
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@@ -140,17 +138,14 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
|
||||
if TYPE_CHECKING:
|
||||
assert item.uid
|
||||
assert current_item
|
||||
assert item.summary
|
||||
|
||||
task = Task(
|
||||
text=item.summary,
|
||||
notes=item.description or "",
|
||||
)
|
||||
|
||||
if (
|
||||
self.entity_description.key is HabiticaTodoList.TODOS
|
||||
and item.due is not None
|
||||
): # Only todos support a due date.
|
||||
task["date"] = item.due
|
||||
date = item.due.isoformat()
|
||||
else:
|
||||
date = None
|
||||
|
||||
if (
|
||||
item.summary != current_item.summary
|
||||
@@ -158,9 +153,13 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
|
||||
or item.due != current_item.due
|
||||
):
|
||||
try:
|
||||
await self.coordinator.habitica.update_task(UUID(item.uid), task)
|
||||
await self.coordinator.api.tasks[item.uid].put(
|
||||
text=item.summary,
|
||||
notes=item.description or "",
|
||||
date=date,
|
||||
)
|
||||
refresh_required = True
|
||||
except (HabiticaException, ClientError) as e:
|
||||
except ClientResponseError as e:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key=f"update_{self.entity_description.key}_item_failed",
|
||||
@@ -173,33 +172,32 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
|
||||
current_item.status is TodoItemStatus.NEEDS_ACTION
|
||||
and item.status == TodoItemStatus.COMPLETED
|
||||
):
|
||||
score_result = await self.coordinator.habitica.update_score(
|
||||
UUID(item.uid), Direction.UP
|
||||
score_result = (
|
||||
await self.coordinator.api.tasks[item.uid].score["up"].post()
|
||||
)
|
||||
refresh_required = True
|
||||
elif (
|
||||
current_item.status is TodoItemStatus.COMPLETED
|
||||
and item.status == TodoItemStatus.NEEDS_ACTION
|
||||
):
|
||||
score_result = await self.coordinator.habitica.update_score(
|
||||
UUID(item.uid), Direction.DOWN
|
||||
score_result = (
|
||||
await self.coordinator.api.tasks[item.uid].score["down"].post()
|
||||
)
|
||||
refresh_required = True
|
||||
else:
|
||||
score_result = None
|
||||
|
||||
except (HabiticaException, ClientError) as e:
|
||||
except ClientResponseError as e:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key=f"score_{self.entity_description.key}_item_failed",
|
||||
translation_placeholders={"name": item.summary or ""},
|
||||
) from e
|
||||
|
||||
if score_result and score_result.data.tmp.drop.key:
|
||||
drop = score_result.data.tmp.drop
|
||||
if score_result and (drop := score_result.get("_tmp", {}).get("drop", False)):
|
||||
msg = (
|
||||
f"\n"
|
||||
f"{drop.dialog}"
|
||||
f"![{drop["key"]}]({ASSETS_URL}Pet_{drop["type"]}_{drop["key"]}.png)\n"
|
||||
f"{drop["dialog"]}"
|
||||
)
|
||||
persistent_notification.async_create(
|
||||
self.hass, message=msg, title="Habitica"
|
||||
@@ -231,36 +229,38 @@ class HabiticaTodosListEntity(BaseHabiticaListEntity):
|
||||
return [
|
||||
*(
|
||||
TodoItem(
|
||||
uid=str(task.id),
|
||||
summary=task.text,
|
||||
description=task.notes,
|
||||
due=dt_util.as_local(task.date).date() if task.date else None,
|
||||
uid=task["id"],
|
||||
summary=task["text"],
|
||||
description=task["notes"],
|
||||
due=(
|
||||
dt_util.as_local(
|
||||
datetime.datetime.fromisoformat(task["date"])
|
||||
).date()
|
||||
if task.get("date")
|
||||
else None
|
||||
),
|
||||
status=(
|
||||
TodoItemStatus.NEEDS_ACTION
|
||||
if not task.completed
|
||||
if not task["completed"]
|
||||
else TodoItemStatus.COMPLETED
|
||||
),
|
||||
)
|
||||
for task in self.coordinator.data.tasks
|
||||
if task.Type is TaskType.TODO
|
||||
if task["type"] == HabiticaTaskType.TODO
|
||||
),
|
||||
]
|
||||
|
||||
async def async_create_todo_item(self, item: TodoItem) -> None:
|
||||
"""Create a Habitica todo."""
|
||||
if TYPE_CHECKING:
|
||||
assert item.summary
|
||||
assert item.description
|
||||
|
||||
try:
|
||||
await self.coordinator.habitica.create_task(
|
||||
Task(
|
||||
text=item.summary,
|
||||
type=TaskType.TODO,
|
||||
notes=item.description,
|
||||
date=item.due,
|
||||
)
|
||||
await self.coordinator.api.tasks.user.post(
|
||||
text=item.summary,
|
||||
type=HabiticaTaskType.TODO,
|
||||
notes=item.description,
|
||||
date=item.due.isoformat() if item.due else None,
|
||||
)
|
||||
except (HabiticaException, ClientError) as e:
|
||||
except ClientResponseError as e:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key=f"create_{self.entity_description.key}_item_failed",
|
||||
@@ -295,23 +295,23 @@ class HabiticaDailiesListEntity(BaseHabiticaListEntity):
|
||||
that have been completed but forgotten to mark as completed before resetting the dailies.
|
||||
Changes of the date input field in Home Assistant will be ignored.
|
||||
"""
|
||||
if TYPE_CHECKING:
|
||||
assert self.coordinator.data.user.lastCron
|
||||
|
||||
last_cron = self.coordinator.data.user["lastCron"]
|
||||
|
||||
return [
|
||||
*(
|
||||
TodoItem(
|
||||
uid=str(task.id),
|
||||
summary=task.text,
|
||||
description=task.notes,
|
||||
due=next_due_date(task, self.coordinator.data.user.lastCron),
|
||||
uid=task["id"],
|
||||
summary=task["text"],
|
||||
description=task["notes"],
|
||||
due=next_due_date(task, last_cron),
|
||||
status=(
|
||||
TodoItemStatus.COMPLETED
|
||||
if task.completed
|
||||
if task["completed"]
|
||||
else TodoItemStatus.NEEDS_ACTION
|
||||
),
|
||||
)
|
||||
for task in self.coordinator.data.tasks
|
||||
if task.Type is TaskType.DAILY
|
||||
if task["type"] == HabiticaTaskType.DAILY
|
||||
)
|
||||
]
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import fields
|
||||
import datetime
|
||||
from math import floor
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from dateutil.rrule import (
|
||||
DAILY,
|
||||
@@ -21,7 +20,6 @@ from dateutil.rrule import (
|
||||
YEARLY,
|
||||
rrule,
|
||||
)
|
||||
from habiticalib import ContentData, Frequency, TaskData, UserData
|
||||
|
||||
from homeassistant.components.automation import automations_with_entity
|
||||
from homeassistant.components.script import scripts_with_entity
|
||||
@@ -29,32 +27,50 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
|
||||
def next_due_date(task: TaskData, today: datetime.datetime) -> datetime.date | None:
|
||||
def next_due_date(task: dict[str, Any], last_cron: str) -> datetime.date | None:
|
||||
"""Calculate due date for dailies and yesterdailies."""
|
||||
|
||||
if task.everyX == 0 or not task.nextDue: # grey dailies never become due
|
||||
if task["everyX"] == 0 or not task.get("nextDue"): # grey dailies never become due
|
||||
return None
|
||||
|
||||
today = to_date(last_cron)
|
||||
startdate = to_date(task["startDate"])
|
||||
if TYPE_CHECKING:
|
||||
assert task.startDate
|
||||
assert today
|
||||
assert startdate
|
||||
|
||||
if task.isDue is True and not task.completed:
|
||||
return dt_util.as_local(today).date()
|
||||
if task["isDue"] and not task["completed"]:
|
||||
return to_date(last_cron)
|
||||
|
||||
if task.startDate > today:
|
||||
if task.frequency is Frequency.DAILY or (
|
||||
task.frequency in (Frequency.MONTHLY, Frequency.YEARLY) and task.daysOfMonth
|
||||
if startdate > today:
|
||||
if task["frequency"] == "daily" or (
|
||||
task["frequency"] in ("monthly", "yearly") and task["daysOfMonth"]
|
||||
):
|
||||
return dt_util.as_local(task.startDate).date()
|
||||
return startdate
|
||||
|
||||
if (
|
||||
task.frequency in (Frequency.WEEKLY, Frequency.MONTHLY)
|
||||
and (nextdue := task.nextDue[0])
|
||||
and task.startDate > nextdue
|
||||
task["frequency"] in ("weekly", "monthly")
|
||||
and (nextdue := to_date(task["nextDue"][0]))
|
||||
and startdate > nextdue
|
||||
):
|
||||
return dt_util.as_local(task.nextDue[1]).date()
|
||||
return to_date(task["nextDue"][1])
|
||||
|
||||
return dt_util.as_local(task.nextDue[0]).date()
|
||||
return to_date(task["nextDue"][0])
|
||||
|
||||
|
||||
def to_date(date: str) -> datetime.date | None:
|
||||
"""Convert an iso date to a datetime.date object."""
|
||||
try:
|
||||
return dt_util.as_local(datetime.datetime.fromisoformat(date)).date()
|
||||
except ValueError:
|
||||
# sometimes nextDue dates are JavaScript datetime strings instead of iso:
|
||||
# "Mon May 06 2024 00:00:00 GMT+0200"
|
||||
try:
|
||||
return dt_util.as_local(
|
||||
datetime.datetime.strptime(date, "%a %b %d %Y %H:%M:%S %Z%z")
|
||||
).date()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]:
|
||||
@@ -68,27 +84,30 @@ FREQUENCY_MAP = {"daily": DAILY, "weekly": WEEKLY, "monthly": MONTHLY, "yearly":
|
||||
WEEKDAY_MAP = {"m": MO, "t": TU, "w": WE, "th": TH, "f": FR, "s": SA, "su": SU}
|
||||
|
||||
|
||||
def build_rrule(task: TaskData) -> rrule:
|
||||
def build_rrule(task: dict[str, Any]) -> rrule:
|
||||
"""Build rrule string."""
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert task.frequency
|
||||
assert task.everyX
|
||||
rrule_frequency = FREQUENCY_MAP.get(task.frequency, DAILY)
|
||||
weekdays = [day for key, day in WEEKDAY_MAP.items() if getattr(task.repeat, key)]
|
||||
rrule_frequency = FREQUENCY_MAP.get(task["frequency"], DAILY)
|
||||
weekdays = [
|
||||
WEEKDAY_MAP[day] for day, is_active in task["repeat"].items() if is_active
|
||||
]
|
||||
bymonthday = (
|
||||
task.daysOfMonth if rrule_frequency == MONTHLY and task.daysOfMonth else None
|
||||
task["daysOfMonth"]
|
||||
if rrule_frequency == MONTHLY and task["daysOfMonth"]
|
||||
else None
|
||||
)
|
||||
|
||||
bysetpos = None
|
||||
if rrule_frequency == MONTHLY and task.weeksOfMonth:
|
||||
bysetpos = task.weeksOfMonth
|
||||
if rrule_frequency == MONTHLY and task["weeksOfMonth"]:
|
||||
bysetpos = task["weeksOfMonth"]
|
||||
weekdays = weekdays if weekdays else [MO]
|
||||
|
||||
return rrule(
|
||||
freq=rrule_frequency,
|
||||
interval=task.everyX,
|
||||
dtstart=dt_util.start_of_local_day(task.startDate),
|
||||
interval=task["everyX"],
|
||||
dtstart=dt_util.start_of_local_day(
|
||||
datetime.datetime.fromisoformat(task["startDate"])
|
||||
),
|
||||
byweekday=weekdays if rrule_frequency in [WEEKLY, MONTHLY] else None,
|
||||
bymonthday=bymonthday,
|
||||
bysetpos=bysetpos,
|
||||
@@ -124,37 +143,48 @@ def get_recurrence_rule(recurrence: rrule) -> str:
|
||||
|
||||
|
||||
def get_attribute_points(
|
||||
user: UserData, content: ContentData, attribute: str
|
||||
user: dict[str, Any], content: dict[str, Any], attribute: str
|
||||
) -> dict[str, float]:
|
||||
"""Get modifiers contributing to STR/INT/CON/PER attributes."""
|
||||
"""Get modifiers contributing to strength attribute."""
|
||||
|
||||
gear_set = {
|
||||
"weapon",
|
||||
"armor",
|
||||
"head",
|
||||
"shield",
|
||||
"back",
|
||||
"headAccessory",
|
||||
"eyewear",
|
||||
"body",
|
||||
}
|
||||
|
||||
equipment = sum(
|
||||
getattr(stats, attribute)
|
||||
for gear in fields(user.items.gear.equipped)
|
||||
if (equipped := getattr(user.items.gear.equipped, gear.name))
|
||||
and (stats := content.gear.flat[equipped])
|
||||
stats[attribute]
|
||||
for gear in gear_set
|
||||
if (equipped := user["items"]["gear"]["equipped"].get(gear))
|
||||
and (stats := content["gear"]["flat"].get(equipped))
|
||||
)
|
||||
|
||||
class_bonus = sum(
|
||||
getattr(stats, attribute) / 2
|
||||
for gear in fields(user.items.gear.equipped)
|
||||
if (equipped := getattr(user.items.gear.equipped, gear.name))
|
||||
and (stats := content.gear.flat[equipped])
|
||||
and stats.klass == user.stats.Class
|
||||
stats[attribute] / 2
|
||||
for gear in gear_set
|
||||
if (equipped := user["items"]["gear"]["equipped"].get(gear))
|
||||
and (stats := content["gear"]["flat"].get(equipped))
|
||||
and stats["klass"] == user["stats"]["class"]
|
||||
)
|
||||
if TYPE_CHECKING:
|
||||
assert user.stats.lvl
|
||||
|
||||
return {
|
||||
"level": min(floor(user.stats.lvl / 2), 50),
|
||||
"level": min(floor(user["stats"]["lvl"] / 2), 50),
|
||||
"equipment": equipment,
|
||||
"class": class_bonus,
|
||||
"allocated": getattr(user.stats, attribute),
|
||||
"buffs": getattr(user.stats.buffs, attribute),
|
||||
"allocated": user["stats"][attribute],
|
||||
"buffs": user["stats"]["buffs"][attribute],
|
||||
}
|
||||
|
||||
|
||||
def get_attributes_total(user: UserData, content: ContentData, attribute: str) -> int:
|
||||
def get_attributes_total(
|
||||
user: dict[str, Any], content: dict[str, Any], attribute: str
|
||||
) -> int:
|
||||
"""Get total attribute points."""
|
||||
return floor(
|
||||
sum(value for value in get_attribute_points(user, content, attribute).values())
|
||||
|
||||
@@ -362,7 +362,7 @@
|
||||
},
|
||||
"addons": {
|
||||
"name": "Add-ons",
|
||||
"description": "List of add-ons to include in the backup. Use the name slug of each add-on."
|
||||
"description": "List of add-ons to include in the backup. Use the name slug of the add-on."
|
||||
},
|
||||
"folders": {
|
||||
"name": "Folders",
|
||||
@@ -418,11 +418,11 @@
|
||||
},
|
||||
"folders": {
|
||||
"name": "[%key:component::hassio::services::backup_partial::fields::folders::name%]",
|
||||
"description": "List of directories to restore from the backup."
|
||||
"description": "[%key:component::hassio::services::backup_partial::fields::folders::description%]"
|
||||
},
|
||||
"addons": {
|
||||
"name": "[%key:component::hassio::services::backup_partial::fields::addons::name%]",
|
||||
"description": "List of add-ons to restore from the backup. Use the name slug of each add-on."
|
||||
"description": "[%key:component::hassio::services::backup_partial::fields::addons::description%]"
|
||||
},
|
||||
"password": {
|
||||
"name": "[%key:common::config_flow::data::password%]",
|
||||
|
||||
@@ -7,32 +7,17 @@ from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from pyheos import (
|
||||
Credentials,
|
||||
Heos,
|
||||
HeosError,
|
||||
HeosOptions,
|
||||
HeosPlayer,
|
||||
const as heos_const,
|
||||
)
|
||||
from pyheos import Heos, HeosError, HeosPlayer, const as heos_const
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
CONF_USERNAME,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_connect,
|
||||
async_dispatcher_send,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
from . import services
|
||||
@@ -48,8 +33,6 @@ PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
|
||||
MIN_UPDATE_SOURCES = timedelta(seconds=1)
|
||||
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -66,12 +49,6 @@ class HeosRuntimeData:
|
||||
type HeosConfigEntry = ConfigEntry[HeosRuntimeData]
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the HEOS component."""
|
||||
services.register(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool:
|
||||
"""Initialize config entry which represents the HEOS controller."""
|
||||
# For backwards compat
|
||||
@@ -79,37 +56,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool
|
||||
hass.config_entries.async_update_entry(entry, unique_id=DOMAIN)
|
||||
|
||||
host = entry.data[CONF_HOST]
|
||||
credentials: Credentials | None = None
|
||||
if entry.options:
|
||||
credentials = Credentials(
|
||||
entry.options[CONF_USERNAME], entry.options[CONF_PASSWORD]
|
||||
)
|
||||
|
||||
# Setting all_progress_events=False ensures that we only receive a
|
||||
# media position update upon start of playback or when media changes
|
||||
controller = Heos(
|
||||
HeosOptions(
|
||||
host,
|
||||
all_progress_events=False,
|
||||
auto_reconnect=True,
|
||||
credentials=credentials,
|
||||
)
|
||||
)
|
||||
|
||||
# Auth failure handler must be added before connecting to the host, otherwise
|
||||
# the event will be missed when login fails during connection.
|
||||
async def auth_failure(event: str) -> None:
|
||||
"""Handle authentication failure."""
|
||||
if event == heos_const.EVENT_USER_CREDENTIALS_INVALID:
|
||||
entry.async_start_reauth(hass)
|
||||
|
||||
entry.async_on_unload(
|
||||
controller.dispatcher.connect(heos_const.SIGNAL_HEOS_EVENT, auth_failure)
|
||||
)
|
||||
|
||||
controller = Heos(host, all_progress_events=False)
|
||||
try:
|
||||
# Auto reconnect only operates if initial connection was successful.
|
||||
await controller.connect()
|
||||
await controller.connect(auto_reconnect=True)
|
||||
# Auto reconnect only operates if initial connection was successful.
|
||||
except HeosError as error:
|
||||
await controller.disconnect()
|
||||
_LOGGER.debug("Unable to connect to controller %s: %s", host, error)
|
||||
@@ -131,7 +83,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool
|
||||
favorites = await controller.get_favorites()
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"The HEOS System is not logged in: Enter credentials in the integration options to access favorites and streaming services"
|
||||
(
|
||||
"%s is not logged in to a HEOS account and will be unable to"
|
||||
" retrieve HEOS favorites: Use the 'heos.sign_in' service to"
|
||||
" sign-in to a HEOS account"
|
||||
),
|
||||
host,
|
||||
)
|
||||
inputs = await controller.get_input_sources()
|
||||
except HeosError as error:
|
||||
@@ -151,6 +108,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool
|
||||
controller_manager, group_manager, source_manager, players
|
||||
)
|
||||
|
||||
services.register(hass, controller)
|
||||
group_manager.connect_update()
|
||||
entry.async_on_unload(group_manager.disconnect_update)
|
||||
|
||||
@@ -162,6 +120,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
await entry.runtime_data.controller_manager.disconnect()
|
||||
|
||||
services.remove(hass)
|
||||
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
@@ -174,6 +135,7 @@ class ControllerManager:
|
||||
self._device_registry = None
|
||||
self._entity_registry = None
|
||||
self.controller = controller
|
||||
self._signals = []
|
||||
|
||||
async def connect_listeners(self):
|
||||
"""Subscribe to events of interest."""
|
||||
@@ -181,17 +143,23 @@ class ControllerManager:
|
||||
self._entity_registry = er.async_get(self._hass)
|
||||
|
||||
# Handle controller events
|
||||
self.controller.dispatcher.connect(
|
||||
heos_const.SIGNAL_CONTROLLER_EVENT, self._controller_event
|
||||
self._signals.append(
|
||||
self.controller.dispatcher.connect(
|
||||
heos_const.SIGNAL_CONTROLLER_EVENT, self._controller_event
|
||||
)
|
||||
)
|
||||
|
||||
# Handle connection-related events
|
||||
self.controller.dispatcher.connect(
|
||||
heos_const.SIGNAL_HEOS_EVENT, self._heos_event
|
||||
self._signals.append(
|
||||
self.controller.dispatcher.connect(
|
||||
heos_const.SIGNAL_HEOS_EVENT, self._heos_event
|
||||
)
|
||||
)
|
||||
|
||||
async def disconnect(self):
|
||||
"""Disconnect subscriptions."""
|
||||
for signal_remove in self._signals:
|
||||
signal_remove()
|
||||
self._signals.clear()
|
||||
self.controller.dispatcher.disconnect_all()
|
||||
await self.controller.disconnect()
|
||||
|
||||
|
||||
@@ -1,37 +1,17 @@
|
||||
"""Config flow to configure Heos."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pyheos import CommandFailedError, Heos, HeosError, HeosOptions
|
||||
from pyheos import Heos, HeosError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import ssdp
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import selector
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
AUTH_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_USERNAME): selector.TextSelector(),
|
||||
vol.Optional(CONF_PASSWORD): selector.TextSelector(
|
||||
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def format_title(host: str) -> str:
|
||||
"""Format the title for config entries."""
|
||||
@@ -40,7 +20,7 @@ def format_title(host: str) -> str:
|
||||
|
||||
async def _validate_host(host: str, errors: dict[str, str]) -> bool:
|
||||
"""Validate host is reachable, return True, otherwise populate errors and return False."""
|
||||
heos = Heos(HeosOptions(host, events=False, heart_beat=False))
|
||||
heos = Heos(host)
|
||||
try:
|
||||
await heos.connect()
|
||||
except HeosError:
|
||||
@@ -51,65 +31,11 @@ async def _validate_host(host: str, errors: dict[str, str]) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def _validate_auth(
|
||||
user_input: dict[str, str], heos: Heos, errors: dict[str, str]
|
||||
) -> bool:
|
||||
"""Validate authentication by signing in or out, otherwise populate errors if needed."""
|
||||
if not user_input:
|
||||
# Log out (neither username nor password provided)
|
||||
try:
|
||||
await heos.sign_out()
|
||||
except HeosError:
|
||||
errors["base"] = "unknown"
|
||||
_LOGGER.exception("Unexpected error occurred during sign-out")
|
||||
return False
|
||||
else:
|
||||
_LOGGER.debug("Successfully signed-out of HEOS Account")
|
||||
return True
|
||||
|
||||
# Ensure both username and password are provided
|
||||
authentication = CONF_USERNAME in user_input or CONF_PASSWORD in user_input
|
||||
if authentication and CONF_USERNAME not in user_input:
|
||||
errors[CONF_USERNAME] = "username_missing"
|
||||
return False
|
||||
if authentication and CONF_PASSWORD not in user_input:
|
||||
errors[CONF_PASSWORD] = "password_missing"
|
||||
return False
|
||||
|
||||
# Attempt to login (both username and password provided)
|
||||
try:
|
||||
await heos.sign_in(user_input[CONF_USERNAME], user_input[CONF_PASSWORD])
|
||||
except CommandFailedError as err:
|
||||
if err.error_id in (6, 8, 10): # Auth-specific errors
|
||||
errors["base"] = "invalid_auth"
|
||||
_LOGGER.warning("Failed to sign-in to HEOS Account: %s", err)
|
||||
else:
|
||||
errors["base"] = "unknown"
|
||||
_LOGGER.exception("Unexpected error occurred during sign-in")
|
||||
return False
|
||||
except HeosError:
|
||||
errors["base"] = "unknown"
|
||||
_LOGGER.exception("Unexpected error occurred during sign-in")
|
||||
return False
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Successfully signed-in to HEOS Account: %s",
|
||||
heos.signed_in_username,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
class HeosFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Define a flow for HEOS."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
|
||||
"""Create the options flow."""
|
||||
return HeosOptionsFlowHandler()
|
||||
|
||||
async def async_step_ssdp(
|
||||
self, discovery_info: ssdp.SsdpServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
@@ -174,52 +100,3 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Perform reauthentication after auth failure event."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Validate account credentials and update options."""
|
||||
errors: dict[str, str] = {}
|
||||
entry = self._get_reauth_entry()
|
||||
if user_input is not None:
|
||||
heos = cast(Heos, entry.runtime_data.controller_manager.controller)
|
||||
if await _validate_auth(user_input, heos, errors):
|
||||
return self.async_update_reload_and_abort(entry, options=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
errors=errors,
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
AUTH_SCHEMA, user_input or entry.options
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class HeosOptionsFlowHandler(OptionsFlow):
|
||||
"""Define HEOS options flow."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Manage the options."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
heos = cast(
|
||||
Heos, self.config_entry.runtime_data.controller_manager.controller
|
||||
)
|
||||
if await _validate_auth(user_input, heos, errors):
|
||||
return self.async_create_entry(data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
errors=errors,
|
||||
step_id="init",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
AUTH_SCHEMA, user_input or self.config_entry.options
|
||||
),
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/heos",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyheos"],
|
||||
"requirements": ["pyheos==0.8.0"],
|
||||
"requirements": ["pyheos==0.7.2"],
|
||||
"single_config_entry": true,
|
||||
"ssdp": [
|
||||
{
|
||||
|
||||
@@ -123,6 +123,7 @@ class HeosMediaPlayer(MediaPlayerEntity):
|
||||
"""Initialize."""
|
||||
self._media_position_updated_at = None
|
||||
self._player = player
|
||||
self._signals: list = []
|
||||
self._source_manager = source_manager
|
||||
self._group_manager = group_manager
|
||||
self._attr_unique_id = str(player.player_id)
|
||||
@@ -149,13 +150,13 @@ class HeosMediaPlayer(MediaPlayerEntity):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Device added to hass."""
|
||||
# Update state when attributes of the player change
|
||||
self.async_on_remove(
|
||||
self._signals.append(
|
||||
self._player.heos.dispatcher.connect(
|
||||
heos_const.SIGNAL_PLAYER_EVENT, self._player_update
|
||||
)
|
||||
)
|
||||
# Update state when heos changes
|
||||
self.async_on_remove(
|
||||
self._signals.append(
|
||||
async_dispatcher_connect(self.hass, SIGNAL_HEOS_UPDATED, self._heos_updated)
|
||||
)
|
||||
# Register this player's entity_id so it can be resolved by the group manager
|
||||
@@ -303,6 +304,12 @@ class HeosMediaPlayer(MediaPlayerEntity):
|
||||
self._player.player_id, self.entity_id
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Disconnect the device when removed."""
|
||||
for signal_remove in self._signals:
|
||||
signal_remove()
|
||||
self._signals.clear()
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if the device is available."""
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup: done
|
||||
action-setup:
|
||||
status: todo
|
||||
comment: Future enhancement to move custom actions for login/out into an options flow.
|
||||
appropriate-polling:
|
||||
status: done
|
||||
comment: Integration is a local push integration
|
||||
@@ -15,7 +17,11 @@ rules:
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: todo
|
||||
entity-event-setup: done
|
||||
entity-event-setup:
|
||||
status: todo
|
||||
comment: |
|
||||
Simplify by using async_on_remove instead of keeping track of listeners to remove
|
||||
later in async_will_remove_from_hass.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
@@ -27,7 +33,10 @@ rules:
|
||||
status: todo
|
||||
comment: Actions currently only log and instead should raise exceptions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-configuration-parameters:
|
||||
status: done
|
||||
comment: |
|
||||
The integration doesn't provide any additional configuration parameters.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
@@ -38,7 +47,10 @@ rules:
|
||||
parallel-updates:
|
||||
status: todo
|
||||
comment: Needs to be set to 0. The underlying library handles parallel updates.
|
||||
reauthentication-flow: done
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration doesn't require re-authentication.
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: |
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
"""Services for the HEOS integration."""
|
||||
|
||||
import functools
|
||||
import logging
|
||||
|
||||
from pyheos import CommandFailedError, Heos, HeosError, const
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import (
|
||||
ATTR_PASSWORD,
|
||||
@@ -27,50 +26,30 @@ HEOS_SIGN_IN_SCHEMA = vol.Schema(
|
||||
HEOS_SIGN_OUT_SCHEMA = vol.Schema({})
|
||||
|
||||
|
||||
def register(hass: HomeAssistant):
|
||||
def register(hass: HomeAssistant, controller: Heos):
|
||||
"""Register HEOS services."""
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SIGN_IN,
|
||||
_sign_in_handler,
|
||||
functools.partial(_sign_in_handler, controller),
|
||||
schema=HEOS_SIGN_IN_SCHEMA,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SIGN_OUT,
|
||||
_sign_out_handler,
|
||||
functools.partial(_sign_out_handler, controller),
|
||||
schema=HEOS_SIGN_OUT_SCHEMA,
|
||||
)
|
||||
|
||||
|
||||
def _get_controller(hass: HomeAssistant) -> Heos:
|
||||
"""Get the HEOS controller instance."""
|
||||
|
||||
_LOGGER.warning(
|
||||
"Actions 'heos.sign_in' and 'heos.sign_out' are deprecated and will be removed in the 2025.8.0 release"
|
||||
)
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"sign_in_out_deprecated",
|
||||
breaks_in_ha_version="2025.8.0",
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="sign_in_out_deprecated",
|
||||
)
|
||||
|
||||
entry = hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, DOMAIN)
|
||||
if not entry or not entry.state == ConfigEntryState.LOADED:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="integration_not_loaded"
|
||||
)
|
||||
return entry.runtime_data.controller_manager.controller
|
||||
def remove(hass: HomeAssistant):
|
||||
"""Unregister HEOS services."""
|
||||
hass.services.async_remove(DOMAIN, SERVICE_SIGN_IN)
|
||||
hass.services.async_remove(DOMAIN, SERVICE_SIGN_OUT)
|
||||
|
||||
|
||||
async def _sign_in_handler(service: ServiceCall) -> None:
|
||||
async def _sign_in_handler(controller: Heos, service: ServiceCall) -> None:
|
||||
"""Sign in to the HEOS account."""
|
||||
|
||||
controller = _get_controller(service.hass)
|
||||
if controller.connection_state != const.STATE_CONNECTED:
|
||||
_LOGGER.error("Unable to sign in because HEOS is not connected")
|
||||
return
|
||||
@@ -84,10 +63,8 @@ async def _sign_in_handler(service: ServiceCall) -> None:
|
||||
_LOGGER.error("Unable to sign in: %s", err)
|
||||
|
||||
|
||||
async def _sign_out_handler(service: ServiceCall) -> None:
|
||||
async def _sign_out_handler(controller: Heos, service: ServiceCall) -> None:
|
||||
"""Sign out of the HEOS account."""
|
||||
|
||||
controller = _get_controller(service.hass)
|
||||
if controller.connection_state != const.STATE_CONNECTED:
|
||||
_LOGGER.error("Unable to sign out because HEOS is not connected")
|
||||
return
|
||||
|
||||
@@ -20,56 +20,17 @@
|
||||
"data_description": {
|
||||
"host": "[%key:component::heos::config::step::user::data_description::host%]"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "Reauthenticate HEOS",
|
||||
"description": "Please update your HEOS Account credentials. Alternatively, you can clear the credentials if you do not want the integration to access favorites, playlists, and streaming services.",
|
||||
"data": {
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"username": "[%key:component::heos::options::step::init::data_description::username%]",
|
||||
"password": "[%key:component::heos::options::step::init::data_description::password%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"username_missing": "[%key:component::heos::options::error::username_missing%]",
|
||||
"password_missing": "[%key:component::heos::options::error::password_missing%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "HEOS Options",
|
||||
"description": "You can sign-in to your HEOS Account to access favorites, streaming services, and other features. Clearing the credentials will sign-out of your account.",
|
||||
"data": {
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"username": "The username or email address of your HEOS Account.",
|
||||
"password": "The password to your HEOS Account."
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"username_missing": "Username is missing",
|
||||
"password_missing": "Password is missing",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"sign_in": {
|
||||
"name": "Sign in",
|
||||
@@ -89,16 +50,5 @@
|
||||
"name": "Sign out",
|
||||
"description": "Signs out of the HEOS account."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"integration_not_loaded": {
|
||||
"message": "The HEOS integration is not loaded"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"sign_in_out_deprecated": {
|
||||
"title": "HEOS Actions Deprecated",
|
||||
"description": "Actions 'heos.sign_in' and 'heos.sign_out' are deprecated and will be removed in the 2025.8.0 release. Enter your HEOS Account credentials in the configuration options and the integration will manage authentication automatically."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"exceptions": {
|
||||
"appliance_not_found": {
|
||||
"message": "Appliance for device ID {device_id} not found"
|
||||
"message": "Appliance for device id {device_id} not found"
|
||||
},
|
||||
"turn_on_light": {
|
||||
"message": "Error turning on {entity_id}: {description}"
|
||||
@@ -103,7 +103,7 @@
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Device ID",
|
||||
"description": "ID of the device."
|
||||
"description": "Id of the device."
|
||||
},
|
||||
"program": { "name": "Program", "description": "Program to select." },
|
||||
"key": { "name": "Option key", "description": "Key of the option." },
|
||||
|
||||
@@ -7,12 +7,17 @@ import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from universal_silabs_flasher.const import ApplicationType
|
||||
|
||||
from homeassistant.components.hassio import (
|
||||
AddonError,
|
||||
AddonInfo,
|
||||
AddonManager,
|
||||
AddonState,
|
||||
)
|
||||
from homeassistant.components.zha.repairs.wrong_silabs_firmware import (
|
||||
probe_silabs_firmware_type,
|
||||
)
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigEntryBaseFlow,
|
||||
@@ -27,11 +32,9 @@ from homeassistant.helpers.hassio import is_hassio
|
||||
from . import silabs_multiprotocol_addon
|
||||
from .const import ZHA_DOMAIN
|
||||
from .util import (
|
||||
ApplicationType,
|
||||
get_otbr_addon_manager,
|
||||
get_zha_device_path,
|
||||
get_zigbee_flasher_addon_manager,
|
||||
probe_silabs_firmware_type,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
{
|
||||
"domain": "homeassistant_hardware",
|
||||
"name": "Home Assistant Hardware",
|
||||
"after_dependencies": ["hassio"],
|
||||
"after_dependencies": ["hassio", "zha"],
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
|
||||
"integration_type": "system",
|
||||
"requirements": ["universal-silabs-flasher==0.0.25"]
|
||||
"integration_type": "system"
|
||||
}
|
||||
|
||||
@@ -3,14 +3,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType
|
||||
from universal_silabs_flasher.flasher import Flasher
|
||||
from universal_silabs_flasher.const import ApplicationType
|
||||
|
||||
from homeassistant.components.hassio import AddonError, AddonState
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
@@ -35,26 +32,6 @@ from .silabs_multiprotocol_addon import (
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ApplicationType(StrEnum):
|
||||
"""Application type running on a device."""
|
||||
|
||||
GECKO_BOOTLOADER = "bootloader"
|
||||
CPC = "cpc"
|
||||
EZSP = "ezsp"
|
||||
SPINEL = "spinel"
|
||||
|
||||
@classmethod
|
||||
def from_flasher_application_type(
|
||||
cls, app_type: FlasherApplicationType
|
||||
) -> ApplicationType:
|
||||
"""Convert a USF application type enum."""
|
||||
return cls(app_type.value)
|
||||
|
||||
def as_flasher_application_type(self) -> FlasherApplicationType:
|
||||
"""Convert the application type enum into one compatible with USF."""
|
||||
return FlasherApplicationType(self.value)
|
||||
|
||||
|
||||
def get_zha_device_path(config_entry: ConfigEntry) -> str | None:
|
||||
"""Get the device path from a ZHA config entry."""
|
||||
return cast(str | None, config_entry.data.get("device", {}).get("path", None))
|
||||
@@ -160,27 +137,3 @@ async def guess_firmware_type(hass: HomeAssistant, device_path: str) -> Firmware
|
||||
assert guesses
|
||||
|
||||
return guesses[-1]
|
||||
|
||||
|
||||
async def probe_silabs_firmware_type(
|
||||
device: str, *, probe_methods: Iterable[ApplicationType] | None = None
|
||||
) -> ApplicationType | None:
|
||||
"""Probe the running firmware on a Silabs device."""
|
||||
flasher = Flasher(
|
||||
device=device,
|
||||
**(
|
||||
{"probe_methods": [m.as_flasher_application_type() for m in probe_methods]}
|
||||
if probe_methods
|
||||
else {}
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
await flasher.probe_app_type()
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.debug("Failed to probe application type", exc_info=True)
|
||||
|
||||
if flasher.app_type is None:
|
||||
return None
|
||||
|
||||
return ApplicationType.from_flasher_application_type(flasher.app_type)
|
||||
|
||||
@@ -5,12 +5,13 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, Protocol
|
||||
|
||||
from universal_silabs_flasher.const import ApplicationType
|
||||
|
||||
from homeassistant.components import usb
|
||||
from homeassistant.components.homeassistant_hardware import (
|
||||
firmware_config_flow,
|
||||
silabs_multiprotocol_addon,
|
||||
)
|
||||
from homeassistant.components.homeassistant_hardware.util import ApplicationType
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigEntryBaseFlow,
|
||||
|
||||
@@ -8,6 +8,7 @@ import logging
|
||||
from typing import Any, final
|
||||
|
||||
import aiohttp
|
||||
from universal_silabs_flasher.const import ApplicationType
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.hassio import (
|
||||
@@ -24,7 +25,6 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon
|
||||
OptionsFlowHandler as MultiprotocolOptionsFlowHandler,
|
||||
SerialPortSettings as MultiprotocolSerialPortSettings,
|
||||
)
|
||||
from homeassistant.components.homeassistant_hardware.util import ApplicationType
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_HARDWARE,
|
||||
ConfigEntry,
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
"""The Homee integration."""
|
||||
|
||||
import logging
|
||||
|
||||
from pyHomee import Homee, HomeeAuthFailedException, HomeeConnectionFailedException
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [Platform.COVER]
|
||||
|
||||
type HomeeConfigEntry = ConfigEntry[Homee]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> bool:
|
||||
"""Set up homee from a config entry."""
|
||||
# Create the Homee api object using host, user,
|
||||
# password & pyHomee instance from the config
|
||||
homee = Homee(
|
||||
host=entry.data[CONF_HOST],
|
||||
user=entry.data[CONF_USERNAME],
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
device="HA_" + hass.config.location_name,
|
||||
reconnect_interval=10,
|
||||
max_retries=100,
|
||||
)
|
||||
|
||||
# Start the homee websocket connection as a new task
|
||||
# and wait until we are connected
|
||||
try:
|
||||
await homee.get_access_token()
|
||||
except HomeeConnectionFailedException as exc:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Connection to Homee failed: {exc.__cause__}"
|
||||
) from exc
|
||||
except HomeeAuthFailedException as exc:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Authentication to Homee failed: {exc.__cause__}"
|
||||
) from exc
|
||||
|
||||
hass.loop.create_task(homee.run())
|
||||
await homee.wait_until_connected()
|
||||
|
||||
entry.runtime_data = homee
|
||||
entry.async_on_unload(homee.disconnect)
|
||||
|
||||
async def _connection_update_callback(connected: bool) -> None:
|
||||
"""Call when the device is notified of changes."""
|
||||
if connected:
|
||||
_LOGGER.warning("Reconnected to Homee at %s", entry.data[CONF_HOST])
|
||||
else:
|
||||
_LOGGER.warning("Disconnected from Homee at %s", entry.data[CONF_HOST])
|
||||
|
||||
await homee.add_connection_listener(_connection_update_callback)
|
||||
|
||||
# create device register entry
|
||||
device_registry = dr.async_get(hass)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
connections={
|
||||
(dr.CONNECTION_NETWORK_MAC, dr.format_mac(homee.settings.mac_address))
|
||||
},
|
||||
identifiers={(DOMAIN, homee.settings.uid)},
|
||||
manufacturer="homee",
|
||||
name=homee.settings.homee_name,
|
||||
model="homee",
|
||||
sw_version=homee.settings.version,
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> bool:
|
||||
"""Unload a homee config entry."""
|
||||
# Unload platforms
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -1,85 +0,0 @@
|
||||
"""Config flow for homee integration."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyHomee import (
|
||||
Homee,
|
||||
HomeeAuthFailedException as HomeeAuthenticationFailedException,
|
||||
HomeeConnectionFailedException,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
AUTH_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): str,
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class HomeeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for homee."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
homee: Homee
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial user step."""
|
||||
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
self.homee = Homee(
|
||||
user_input[CONF_HOST],
|
||||
user_input[CONF_USERNAME],
|
||||
user_input[CONF_PASSWORD],
|
||||
)
|
||||
|
||||
try:
|
||||
await self.homee.get_access_token()
|
||||
except HomeeConnectionFailedException:
|
||||
errors["base"] = "cannot_connect"
|
||||
except HomeeAuthenticationFailedException:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
_LOGGER.info("Got access token for homee")
|
||||
self.hass.loop.create_task(self.homee.run())
|
||||
_LOGGER.debug("Homee task created")
|
||||
await self.homee.wait_until_connected()
|
||||
_LOGGER.info("Homee connected")
|
||||
self.homee.disconnect()
|
||||
_LOGGER.debug("Homee disconnecting")
|
||||
await self.homee.wait_until_disconnected()
|
||||
_LOGGER.info("Homee config successfully tested")
|
||||
|
||||
await self.async_set_unique_id(self.homee.settings.uid)
|
||||
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
_LOGGER.info(
|
||||
"Created new homee entry with ID %s", self.homee.settings.uid
|
||||
)
|
||||
|
||||
return self.async_create_entry(
|
||||
title=f"{self.homee.settings.homee_name} ({self.homee.host})",
|
||||
data=user_input,
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=AUTH_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
@@ -1,4 +0,0 @@
|
||||
"""Constants for the homee integration."""
|
||||
|
||||
# General
|
||||
DOMAIN = "homee"
|
||||
@@ -1,261 +0,0 @@
|
||||
"""The homee cover platform."""
|
||||
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from pyHomee.const import AttributeType, NodeProfile
|
||||
from pyHomee.model import HomeeAttribute, HomeeNode
|
||||
|
||||
from homeassistant.components.cover import (
|
||||
ATTR_POSITION,
|
||||
ATTR_TILT_POSITION,
|
||||
CoverDeviceClass,
|
||||
CoverEntity,
|
||||
CoverEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import HomeeConfigEntry
|
||||
from .entity import HomeeNodeEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
OPEN_CLOSE_ATTRIBUTES = [
|
||||
AttributeType.OPEN_CLOSE,
|
||||
AttributeType.SLAT_ROTATION_IMPULSE,
|
||||
AttributeType.UP_DOWN,
|
||||
]
|
||||
POSITION_ATTRIBUTES = [AttributeType.POSITION, AttributeType.SHUTTER_SLAT_POSITION]
|
||||
|
||||
|
||||
def get_open_close_attribute(node: HomeeNode) -> HomeeAttribute:
|
||||
"""Return the attribute used for opening/closing the cover."""
|
||||
# We assume, that no device has UP_DOWN and OPEN_CLOSE, but only one of them.
|
||||
if (open_close := node.get_attribute_by_type(AttributeType.UP_DOWN)) is None:
|
||||
open_close = node.get_attribute_by_type(AttributeType.OPEN_CLOSE)
|
||||
|
||||
return open_close
|
||||
|
||||
|
||||
def get_cover_features(
|
||||
node: HomeeNode, open_close_attribute: HomeeAttribute
|
||||
) -> CoverEntityFeature:
|
||||
"""Determine the supported cover features of a homee node based on the available attributes."""
|
||||
features = CoverEntityFeature(0)
|
||||
|
||||
if open_close_attribute.editable:
|
||||
features |= (
|
||||
CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP
|
||||
)
|
||||
|
||||
# Check for up/down position settable.
|
||||
attribute = node.get_attribute_by_type(AttributeType.POSITION)
|
||||
if attribute is not None:
|
||||
if attribute.editable:
|
||||
features |= CoverEntityFeature.SET_POSITION
|
||||
|
||||
if node.get_attribute_by_type(AttributeType.SLAT_ROTATION_IMPULSE) is not None:
|
||||
features |= CoverEntityFeature.OPEN_TILT | CoverEntityFeature.CLOSE_TILT
|
||||
|
||||
if node.get_attribute_by_type(AttributeType.SHUTTER_SLAT_POSITION) is not None:
|
||||
features |= CoverEntityFeature.SET_TILT_POSITION
|
||||
|
||||
return features
|
||||
|
||||
|
||||
def get_device_class(node: HomeeNode) -> CoverDeviceClass | None:
|
||||
"""Determine the device class a homee node based on the node profile."""
|
||||
COVER_DEVICE_PROFILES = {
|
||||
NodeProfile.GARAGE_DOOR_OPERATOR: CoverDeviceClass.GARAGE,
|
||||
NodeProfile.SHUTTER_POSITION_SWITCH: CoverDeviceClass.SHUTTER,
|
||||
}
|
||||
|
||||
return COVER_DEVICE_PROFILES.get(node.profile)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: HomeeConfigEntry,
|
||||
async_add_devices: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Add the homee platform for the cover integration."""
|
||||
|
||||
async_add_devices(
|
||||
HomeeCover(node, config_entry)
|
||||
for node in config_entry.runtime_data.nodes
|
||||
if is_cover_node(node)
|
||||
)
|
||||
|
||||
|
||||
def is_cover_node(node: HomeeNode) -> bool:
|
||||
"""Determine if a node is controllable as a homee cover based on its profile and attributes."""
|
||||
return node.profile in [
|
||||
NodeProfile.ELECTRIC_MOTOR_METERING_SWITCH,
|
||||
NodeProfile.ELECTRIC_MOTOR_METERING_SWITCH_WITHOUT_SLAT_POSITION,
|
||||
NodeProfile.GARAGE_DOOR_OPERATOR,
|
||||
NodeProfile.SHUTTER_POSITION_SWITCH,
|
||||
]
|
||||
|
||||
|
||||
class HomeeCover(HomeeNodeEntity, CoverEntity):
|
||||
"""Representation of a homee cover device."""
|
||||
|
||||
_attr_name = None
|
||||
|
||||
def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None:
|
||||
"""Initialize a homee cover entity."""
|
||||
super().__init__(node, entry)
|
||||
self._open_close_attribute = get_open_close_attribute(node)
|
||||
self._attr_supported_features = get_cover_features(
|
||||
node, self._open_close_attribute
|
||||
)
|
||||
self._attr_device_class = get_device_class(node)
|
||||
|
||||
self._attr_unique_id = f"{self._attr_unique_id}-{self._open_close_attribute.id}"
|
||||
|
||||
@property
|
||||
def current_cover_position(self) -> int | None:
|
||||
"""Return the cover's position."""
|
||||
# Translate the homee position values to HA's 0-100 scale
|
||||
if self.has_attribute(AttributeType.POSITION):
|
||||
attribute = self._node.get_attribute_by_type(AttributeType.POSITION)
|
||||
homee_min = attribute.minimum
|
||||
homee_max = attribute.maximum
|
||||
homee_position = attribute.current_value
|
||||
position = ((homee_position - homee_min) / (homee_max - homee_min)) * 100
|
||||
|
||||
return 100 - position
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
def current_cover_tilt_position(self) -> int | None:
|
||||
"""Return the cover's tilt position."""
|
||||
# Translate the homee position values to HA's 0-100 scale
|
||||
if self.has_attribute(AttributeType.SHUTTER_SLAT_POSITION):
|
||||
attribute = self._node.get_attribute_by_type(
|
||||
AttributeType.SHUTTER_SLAT_POSITION
|
||||
)
|
||||
homee_min = attribute.minimum
|
||||
homee_max = attribute.maximum
|
||||
homee_position = attribute.current_value
|
||||
position = ((homee_position - homee_min) / (homee_max - homee_min)) * 100
|
||||
|
||||
return 100 - position
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_opening(self) -> bool | None:
|
||||
"""Return the opening status of the cover."""
|
||||
if self._open_close_attribute is not None:
|
||||
return (
|
||||
self._open_close_attribute.get_value() == 3
|
||||
if not self._open_close_attribute.is_reversed
|
||||
else self._open_close_attribute.get_value() == 4
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_closing(self) -> bool | None:
|
||||
"""Return the closing status of the cover."""
|
||||
if self._open_close_attribute is not None:
|
||||
return (
|
||||
self._open_close_attribute.get_value() == 4
|
||||
if not self._open_close_attribute.is_reversed
|
||||
else self._open_close_attribute.get_value() == 3
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool | None:
|
||||
"""Return if the cover is closed."""
|
||||
if self.has_attribute(AttributeType.POSITION):
|
||||
attribute = self._node.get_attribute_by_type(AttributeType.POSITION)
|
||||
return attribute.get_value() == attribute.maximum
|
||||
|
||||
if self._open_close_attribute is not None:
|
||||
if not self._open_close_attribute.is_reversed:
|
||||
return self._open_close_attribute.get_value() == 1
|
||||
|
||||
return self._open_close_attribute.get_value() == 0
|
||||
|
||||
# If none of the above is present, it might be a slat only cover.
|
||||
if self.has_attribute(AttributeType.SHUTTER_SLAT_POSITION):
|
||||
attribute = self._node.get_attribute_by_type(
|
||||
AttributeType.SHUTTER_SLAT_POSITION
|
||||
)
|
||||
return attribute.get_value() == attribute.minimum
|
||||
|
||||
return None
|
||||
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Open the cover."""
|
||||
if not self._open_close_attribute.is_reversed:
|
||||
await self.async_set_value(self._open_close_attribute, 0)
|
||||
else:
|
||||
await self.async_set_value(self._open_close_attribute, 1)
|
||||
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Close cover."""
|
||||
if not self._open_close_attribute.is_reversed:
|
||||
await self.async_set_value(self._open_close_attribute, 1)
|
||||
else:
|
||||
await self.async_set_value(self._open_close_attribute, 0)
|
||||
|
||||
async def async_set_cover_position(self, **kwargs: Any) -> None:
|
||||
"""Move the cover to a specific position."""
|
||||
if CoverEntityFeature.SET_POSITION in self.supported_features:
|
||||
position = 100 - cast(int, kwargs[ATTR_POSITION])
|
||||
|
||||
# Convert position to range of our entity.
|
||||
attribute = self._node.get_attribute_by_type(AttributeType.POSITION)
|
||||
homee_min = attribute.minimum
|
||||
homee_max = attribute.maximum
|
||||
homee_position = (position / 100) * (homee_max - homee_min) + homee_min
|
||||
|
||||
await self.async_set_value(AttributeType.POSITION, homee_position)
|
||||
|
||||
async def async_stop_cover(self, **kwargs: Any) -> None:
|
||||
"""Stop the cover."""
|
||||
await self.async_set_value(self._open_close_attribute, 2)
|
||||
|
||||
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Open the cover tilt."""
|
||||
slat_attribute = self._node.get_attribute_by_type(
|
||||
AttributeType.SLAT_ROTATION_IMPULSE
|
||||
)
|
||||
if not slat_attribute.is_reversed:
|
||||
await self.async_set_value(AttributeType.SLAT_ROTATION_IMPULSE, 2)
|
||||
else:
|
||||
await self.async_set_value(AttributeType.SLAT_ROTATION_IMPULSE, 1)
|
||||
|
||||
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Close the cover tilt."""
|
||||
slat_attribute = self._node.get_attribute_by_type(
|
||||
AttributeType.SLAT_ROTATION_IMPULSE
|
||||
)
|
||||
if not slat_attribute.is_reversed:
|
||||
await self.async_set_value(AttributeType.SLAT_ROTATION_IMPULSE, 1)
|
||||
else:
|
||||
await self.async_set_value(AttributeType.SLAT_ROTATION_IMPULSE, 2)
|
||||
|
||||
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
|
||||
"""Move the cover tilt to a specific position."""
|
||||
if CoverEntityFeature.SET_TILT_POSITION in self.supported_features:
|
||||
position = 100 - cast(int, kwargs[ATTR_TILT_POSITION])
|
||||
|
||||
# Convert position to range of our entity.
|
||||
attribute = self._node.get_attribute_by_type(
|
||||
AttributeType.SHUTTER_SLAT_POSITION
|
||||
)
|
||||
homee_min = attribute.minimum
|
||||
homee_max = attribute.maximum
|
||||
homee_position = (position / 100) * (homee_max - homee_min) + homee_min
|
||||
|
||||
await self.async_set_value(
|
||||
AttributeType.SHUTTER_SLAT_POSITION, homee_position
|
||||
)
|
||||
@@ -1,88 +0,0 @@
|
||||
"""Base Entities for Homee integration."""
|
||||
|
||||
from pyHomee.const import AttributeType, NodeProfile, NodeState
|
||||
from pyHomee.model import HomeeNode
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from . import HomeeConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .helpers import get_name_for_enum
|
||||
|
||||
|
||||
class HomeeNodeEntity(Entity):
|
||||
"""Representation of an Entity that uses more than one HomeeAttribute."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None:
|
||||
"""Initialize the wrapper using a HomeeNode and target entity."""
|
||||
self._node = node
|
||||
self._attr_unique_id = f"{entry.runtime_data.settings.uid}-{node.id}"
|
||||
self._entry = entry
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, str(node.id))},
|
||||
name=node.name,
|
||||
model=get_name_for_enum(NodeProfile, node.profile),
|
||||
sw_version=self._get_software_version(),
|
||||
via_device=(DOMAIN, entry.runtime_data.settings.uid),
|
||||
)
|
||||
self._host_connected = entry.runtime_data.connected
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Add the homee binary sensor device to home assistant."""
|
||||
self.async_on_remove(self._node.add_on_changed_listener(self._on_node_updated))
|
||||
self.async_on_remove(
|
||||
await self._entry.runtime_data.add_connection_listener(
|
||||
self._on_connection_changed
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return the availability of the underlying node."""
|
||||
return self._node.state == NodeState.AVAILABLE and self._host_connected
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Fetch new state data for this node."""
|
||||
# Base class requests the whole node, if only a single attribute is needed
|
||||
# the platform will overwrite this method.
|
||||
homee = self._entry.runtime_data
|
||||
await homee.update_node(self._node.id)
|
||||
|
||||
def _get_software_version(self) -> str | None:
|
||||
"""Return the software version of the node."""
|
||||
if self.has_attribute(AttributeType.FIRMWARE_REVISION):
|
||||
return self._node.get_attribute_by_type(
|
||||
AttributeType.FIRMWARE_REVISION
|
||||
).get_value()
|
||||
if self.has_attribute(AttributeType.SOFTWARE_REVISION):
|
||||
return self._node.get_attribute_by_type(
|
||||
AttributeType.SOFTWARE_REVISION
|
||||
).get_value()
|
||||
return None
|
||||
|
||||
def has_attribute(self, attribute_type: AttributeType) -> bool:
|
||||
"""Check if an attribute of the given type exists."""
|
||||
return attribute_type in self._node.attribute_map
|
||||
|
||||
async def async_set_value(self, attribute_type: int, value: float) -> None:
|
||||
"""Set an attribute value on the homee node."""
|
||||
await self.async_set_value_by_id(
|
||||
self._node.get_attribute_by_type(attribute_type).id, value
|
||||
)
|
||||
|
||||
async def async_set_value_by_id(self, attribute_id: int, value: float) -> None:
|
||||
"""Set an attribute value on the homee node."""
|
||||
homee = self._entry.runtime_data
|
||||
await homee.set_value(self._node.id, attribute_id, value)
|
||||
|
||||
def _on_node_updated(self, node: HomeeNode) -> None:
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
async def _on_connection_changed(self, connected: bool) -> None:
|
||||
self._host_connected = connected
|
||||
self.schedule_update_ha_state()
|
||||
@@ -1,16 +0,0 @@
|
||||
"""Helper functions for the homee custom component."""
|
||||
|
||||
import logging
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_name_for_enum(att_class, att_id) -> str:
|
||||
"""Return the enum item name for a given integer."""
|
||||
try:
|
||||
attribute_name = att_class(att_id).name
|
||||
except ValueError:
|
||||
_LOGGER.warning("Value %s does not exist in %s", att_id, att_class.__name__)
|
||||
return "Unknown"
|
||||
|
||||
return attribute_name
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"domain": "homee",
|
||||
"name": "Homee",
|
||||
"codeowners": ["@Taraman17"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/homee",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["homee"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyHomee==1.2.0"]
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
The integration does not provide any additional actions.
|
||||
appropriate-polling:
|
||||
status: exempt
|
||||
comment: Integration is push based.
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
The integration does not provide any additional actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
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: todo
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
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-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"flow_title": "Homee {name} ({host})",
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Configure homee",
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The IP address of your Homee.",
|
||||
"username": "The username for your Homee.",
|
||||
"password": "The password for your Homee."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/idasen_desk",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["idasen-ha==2.6.3"]
|
||||
}
|
||||
|
||||
@@ -17,9 +17,9 @@ rules:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
docs-high-level-description: done
|
||||
docs-high-level-description: todo
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
docs-removal-instructions: todo
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
"""The igloohome integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from aiohttp import ClientError
|
||||
from igloohome_api import (
|
||||
Api as IgloohomeApi,
|
||||
ApiException,
|
||||
Auth as IgloohomeAuth,
|
||||
AuthException,
|
||||
GetDeviceInfoResponse,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
|
||||
@dataclass
|
||||
class IgloohomeRuntimeData:
|
||||
"""Holding class for runtime data."""
|
||||
|
||||
api: IgloohomeApi
|
||||
devices: list[GetDeviceInfoResponse]
|
||||
|
||||
|
||||
type IgloohomeConfigEntry = ConfigEntry[IgloohomeRuntimeData]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: IgloohomeConfigEntry) -> bool:
|
||||
"""Set up igloohome from a config entry."""
|
||||
|
||||
authentication = IgloohomeAuth(
|
||||
session=async_get_clientsession(hass),
|
||||
client_id=entry.data[CONF_CLIENT_ID],
|
||||
client_secret=entry.data[CONF_CLIENT_SECRET],
|
||||
)
|
||||
|
||||
api = IgloohomeApi(auth=authentication)
|
||||
try:
|
||||
devices = (await api.get_devices()).payload
|
||||
except AuthException as e:
|
||||
raise ConfigEntryError from e
|
||||
except (ApiException, ClientError) as e:
|
||||
raise ConfigEntryNotReady from e
|
||||
|
||||
entry.runtime_data = IgloohomeRuntimeData(api, devices)
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: IgloohomeConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -1,61 +0,0 @@
|
||||
"""Config flow for igloohome integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientError
|
||||
from igloohome_api import Auth as IgloohomeAuth, AuthException
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_CLIENT_ID): str,
|
||||
vol.Required(CONF_CLIENT_SECRET): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class IgloohomeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for igloohome."""
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the config flow step."""
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match(
|
||||
{
|
||||
CONF_CLIENT_ID: user_input[CONF_CLIENT_ID],
|
||||
}
|
||||
)
|
||||
auth = IgloohomeAuth(
|
||||
session=async_get_clientsession(self.hass),
|
||||
client_id=user_input[CONF_CLIENT_ID],
|
||||
client_secret=user_input[CONF_CLIENT_SECRET],
|
||||
)
|
||||
try:
|
||||
await auth.async_get_access_token()
|
||||
except AuthException:
|
||||
errors["base"] = "invalid_auth"
|
||||
except ClientError:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title="Client Credentials", data=user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user