Compare commits

..

42 Commits

Author SHA1 Message Date
Paulus Schoutsen e5e26de06f Bump version to 2024.6.0b3 2024-05-31 02:20:10 +00:00
tronikos 7dab255c15 Fix unnecessary single quotes escaping in Google AI (#118522) 2024-05-31 02:19:58 +00:00
tronikos cea7347ed9 Improve LLM prompt (#118520) 2024-05-31 02:19:56 +00:00
tronikos f4a876c590 Fix LLMs asking which area when there is only one device (#118518)
* Ignore deprecated open and close cover intents for LLMs

* Fix LLMs asking which area when there is only one device

* remove unrelated changed

* remove unrelated changes
2024-05-31 02:19:55 +00:00
tronikos 117a02972d Ignore deprecated open and close cover intents for LLMs (#118515) 2024-05-31 02:19:54 +00:00
G Johansson 3fb40deacb Fix key issue in config entry options in Openweathermap (#118506) 2024-05-31 02:19:54 +00:00
G Johansson 38c88c576b Fix tado non-string unique id for device trackers (#118505)
* Fix tado none string unique id for device trackers

* Add comment

* Fix comment
2024-05-31 02:19:53 +00:00
Paulus Schoutsen e95b63bc89 Intent script: allow setting description and platforms (#118500)
* Add description to intent_script

* Allow setting platforms
2024-05-31 02:19:51 +00:00
Jan Bouwhuis ea44b534e6 Fix group platform dependencies (#118499) 2024-05-31 02:19:50 +00:00
G Johansson 7646d853f4 Remove not needed hass object from Tag (#118498) 2024-05-31 02:19:49 +00:00
G Johansson 248c7c33b2 Fix blocking call in holiday (#118496) 2024-05-31 02:19:48 +00:00
Paulus Schoutsen eb887a707c Ignore the toggle intent (#118491) 2024-05-31 02:19:46 +00:00
David Bonnes e3ddbb2768 Fix evohome so it doesn't retrieve schedules unnecessarily (#118478) 2024-05-31 02:19:45 +00:00
Jan-Philipp Benecke 008aec5670 Log aiohttp error in rest_command (#118453) 2024-05-31 02:19:45 +00:00
Tsvi Mostovicz d93d7159db Fix Jewish calendar unique id's (#117985)
* Initial commit

* Fix updating of unique id

* Add testing to check the unique id is being updated correctly

* Reload the config entry and confirm the unique id has not been changed

* Move updating unique_id to __init__.py as suggested

* Change the config_entry variable's name back from config to config_entry

* Move the loop into the update_unique_ids method

* Move test from test_config_flow to test_init

* Try an early optimization to check if we need to update the unique ids

* Mention the correct version

* Implement suggestions

* Ensure all entities are migrated correctly

* Just to be sure keep the previous assertion as well
2024-05-31 02:19:44 +00:00
Diogo Gomes e6e017dab7 Add support for V2C Trydan 2.1.7 (#117147)
* Support for firmware 2.1.7

* add device ID as unique_id

* add device ID as unique_id

* add test device id as unique_id

* backward compatibility

* move outside try

* Sensor return type

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* not needed

* make slave error enum state

* fix enum

* Update homeassistant/components/v2c/sensor.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/v2c/strings.json

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/v2c/strings.json

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* simplify tests

* fix misspellings from upstream library

* add sensor tests

* just enough coverage for enum sensor

* Refactor V2C tests (#117264)

* Refactor V2C tests

* fix rebase issues

* ruff

* review

* fix https://github.com/home-assistant/core/issues/117296

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2024-05-31 02:19:43 +00:00
dontinelli 486c72db73 Adjustment of unit of measurement for light (#116695) 2024-05-31 02:19:42 +00:00
Franck Nijhof 4beb184faf Bump version to 2024.6.0b2 2024-05-30 17:02:58 +02:00
Bram Kragten 4951b60b1d Update frontend to 20240530.0 (#118489) 2024-05-30 17:02:37 +02:00
Marcel van der Veldt 9095941b62 Mark Matter climate dry/fan mode support on Panasonic AC (#118485) 2024-05-30 17:02:32 +02:00
Marcel van der Veldt e906812fbd Extend Matter sensor discovery schemas for Air Purifier / Air Quality devices (#118483)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2024-05-30 17:02:26 +02:00
Robert Resch 522152e7d2 Set enity_category to config for airgradient select entities (#118477) 2024-05-30 17:02:21 +02:00
lunmay 50acc26812 Typo fix in media_extractor (#118473) 2024-05-30 17:02:16 +02:00
Maciej Bieniek 356374cdc3 Raise ConfigEntryNotReady when there is no _id in the Tractive data (#118467)
Co-authored-by: Maciej Bieniek <478555+bieniu@users.noreply.github.com>
2024-05-30 17:02:11 +02:00
Robert Resch 98d905562e Bump deebot-client to 7.3.0 (#118462) 2024-05-30 17:02:05 +02:00
tronikos 48342837c0 Instruct LLM to not pass a list to the domain (#118451) 2024-05-30 17:02:00 +02:00
tronikos 3e0d9516a9 Improve LLM prompt (#118443)
* Improve LLM prompt

* test

* improvements

* improvements
2024-05-30 17:01:55 +02:00
Alexey Guseynov c6c36718b9 Add Total Volatile Organic Compounds (tVOC) matter discovery schema (#116963) 2024-05-30 17:01:49 +02:00
Franck Nijhof 8ee1d8865c Bump version to 2024.6.0b1 2024-05-30 01:19:49 +02:00
J. Nick Koston 5d5210b47d Fix google_mail doing blocking i/o in the event loop (take 2) (#118441) 2024-05-30 01:18:34 +02:00
tronikos 27cc97bbeb Bump opower to 0.4.6 (#118434) 2024-05-30 01:18:31 +02:00
J. Nick Koston 9728103de4 Fix blocking I/O in the event loop in meteo_france (#118429) 2024-05-30 01:18:28 +02:00
Evgeny ebf9013569 Fix OpenWeatherMap migration (#118428) 2024-05-30 01:18:24 +02:00
J. Nick Koston b75f3d9681 Fix workday doing blocking I/O in the event loop (#118422) 2024-05-30 01:18:21 +02:00
J. Nick Koston 0d4990799f Fix google_mail doing blocking I/O in the event loop (#118421)
fixes #118411
2024-05-30 01:18:17 +02:00
J. Nick Koston 1e77a59561 Fix google_tasks doing blocking I/O in the event loop (#118418)
fixes #118407
2024-05-30 01:17:37 +02:00
J. Nick Koston 7ee2f09fe1 Ensure paho.mqtt.client is imported in the executor (#118412)
fixes #118405
2024-05-30 00:12:34 +02:00
Michael Hansen 23d9b4b17f Handle case where timer device id exists but is not registered (delayed command) (#118410)
Handle case where device id exists but is not registered
2024-05-30 00:12:30 +02:00
Marcel van der Veldt a580d834da Fix light discovery for Matter dimmable plugin unit (#118404) 2024-05-30 00:12:27 +02:00
Marcel van der Veldt 4fb6e59fdc Add translation strings for Matter Fan presets (#118401) 2024-05-30 00:12:24 +02:00
swcloudgenie ad3823764a New official genie garage integration (#117020)
* new official genie garage integration

* move api constants into api module

* move scan interval constant to cover.py
2024-05-30 00:12:19 +02:00
Franck Nijhof 024de4f8a6 Bump version to 2024.6.0b0 2024-05-29 20:17:13 +02:00
2073 changed files with 22724 additions and 44396 deletions
+13 -7
View File
@@ -62,6 +62,7 @@ omit =
homeassistant/components/aladdin_connect/api.py
homeassistant/components/aladdin_connect/application_credentials.py
homeassistant/components/aladdin_connect/cover.py
homeassistant/components/aladdin_connect/model.py
homeassistant/components/aladdin_connect/sensor.py
homeassistant/components/alarmdecoder/__init__.py
homeassistant/components/alarmdecoder/alarm_control_panel.py
@@ -89,7 +90,6 @@ omit =
homeassistant/components/aprilaire/entity.py
homeassistant/components/apsystems/__init__.py
homeassistant/components/apsystems/coordinator.py
homeassistant/components/apsystems/entity.py
homeassistant/components/apsystems/sensor.py
homeassistant/components/aqualogic/*
homeassistant/components/aquostv/media_player.py
@@ -149,7 +149,12 @@ omit =
homeassistant/components/bloomsky/*
homeassistant/components/bluesound/*
homeassistant/components/bluetooth_tracker/*
homeassistant/components/bmw_connected_drive/__init__.py
homeassistant/components/bmw_connected_drive/binary_sensor.py
homeassistant/components/bmw_connected_drive/coordinator.py
homeassistant/components/bmw_connected_drive/lock.py
homeassistant/components/bmw_connected_drive/notify.py
homeassistant/components/bmw_connected_drive/sensor.py
homeassistant/components/bosch_shc/__init__.py
homeassistant/components/bosch_shc/binary_sensor.py
homeassistant/components/bosch_shc/cover.py
@@ -590,9 +595,7 @@ omit =
homeassistant/components/ifttt/alarm_control_panel.py
homeassistant/components/iglo/light.py
homeassistant/components/ihc/*
homeassistant/components/incomfort/__init__.py
homeassistant/components/incomfort/climate.py
homeassistant/components/incomfort/water_heater.py
homeassistant/components/incomfort/*
homeassistant/components/insteon/binary_sensor.py
homeassistant/components/insteon/climate.py
homeassistant/components/insteon/cover.py
@@ -622,7 +625,6 @@ omit =
homeassistant/components/irish_rail_transport/sensor.py
homeassistant/components/iss/__init__.py
homeassistant/components/iss/sensor.py
homeassistant/components/ista_ecotrend/coordinator.py
homeassistant/components/isy994/__init__.py
homeassistant/components/isy994/binary_sensor.py
homeassistant/components/isy994/button.py
@@ -982,7 +984,6 @@ omit =
homeassistant/components/orvibo/switch.py
homeassistant/components/osoenergy/__init__.py
homeassistant/components/osoenergy/binary_sensor.py
homeassistant/components/osoenergy/entity.py
homeassistant/components/osoenergy/sensor.py
homeassistant/components/osoenergy/water_heater.py
homeassistant/components/osramlightify/light.py
@@ -1469,6 +1470,12 @@ omit =
homeassistant/components/traccar_server/entity.py
homeassistant/components/traccar_server/helpers.py
homeassistant/components/traccar_server/sensor.py
homeassistant/components/tractive/__init__.py
homeassistant/components/tractive/binary_sensor.py
homeassistant/components/tractive/device_tracker.py
homeassistant/components/tractive/entity.py
homeassistant/components/tractive/sensor.py
homeassistant/components/tractive/switch.py
homeassistant/components/tradfri/__init__.py
homeassistant/components/tradfri/base_class.py
homeassistant/components/tradfri/coordinator.py
@@ -1556,7 +1563,6 @@ omit =
homeassistant/components/verisure/sensor.py
homeassistant/components/verisure/switch.py
homeassistant/components/versasense/*
homeassistant/components/vesync/__init__.py
homeassistant/components/vesync/fan.py
homeassistant/components/vesync/light.py
homeassistant/components/vesync/sensor.py
+3 -3
View File
@@ -10,7 +10,7 @@ on:
env:
BUILD_TYPE: core
DEFAULT_PYTHON: "3.12.3"
DEFAULT_PYTHON: "3.12"
PIP_TIMEOUT: 60
UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true"
@@ -94,7 +94,7 @@ jobs:
- name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@v5
uses: dawidd6/action-download-artifact@v3.1.4
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/frontend
@@ -105,7 +105,7 @@ jobs:
- name: Download nightly wheels of intents
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@v5
uses: dawidd6/action-download-artifact@v3.1.4
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/intents-package
+4 -8
View File
@@ -33,12 +33,12 @@ on:
type: boolean
env:
CACHE_VERSION: 9
CACHE_VERSION: 8
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 8
HA_SHORT_VERSION: "2024.7"
DEFAULT_PYTHON: "3.12.3"
ALL_PYTHON_VERSIONS: "['3.12.3']"
HA_SHORT_VERSION: "2024.6"
DEFAULT_PYTHON: "3.12"
ALL_PYTHON_VERSIONS: "['3.12']"
# 10.3 is the oldest supported version
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
# 10.6 is the current long-term-support
@@ -746,7 +746,6 @@ jobs:
- hassfest
- lint-other
- lint-ruff
- lint-ruff-format
- mypy
- prepare-pytest-full
strategy:
@@ -864,7 +863,6 @@ jobs:
- hassfest
- lint-other
- lint-ruff
- lint-ruff-format
- mypy
strategy:
fail-fast: false
@@ -988,7 +986,6 @@ jobs:
- hassfest
- lint-other
- lint-ruff
- lint-ruff-format
- mypy
strategy:
fail-fast: false
@@ -1131,7 +1128,6 @@ jobs:
- hassfest
- lint-other
- lint-ruff
- lint-ruff-format
- mypy
strategy:
fail-fast: false
+2 -2
View File
@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@v4.1.6
- name: Initialize CodeQL
uses: github/codeql-action/init@v3.25.8
uses: github/codeql-action/init@v3.25.6
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.25.8
uses: github/codeql-action/analyze@v3.25.6
with:
category: "/language:python"
+1 -1
View File
@@ -10,7 +10,7 @@ on:
- "**strings.json"
env:
DEFAULT_PYTHON: "3.12.3"
DEFAULT_PYTHON: "3.11"
jobs:
upload:
+1 -1
View File
@@ -17,7 +17,7 @@ on:
- "script/gen_requirements_all.py"
env:
DEFAULT_PYTHON: "3.12.3"
DEFAULT_PYTHON: "3.12"
concurrency:
group: ${{ github.workflow }}-${{ github.ref_name}}
+1 -1
View File
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.8
rev: v0.4.6
hooks:
- id: ruff
args:
+6 -10
View File
@@ -88,7 +88,6 @@ build.json @home-assistant/supervisor
/tests/components/alert/ @home-assistant/core @frenck
/homeassistant/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh
/tests/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh
/homeassistant/components/amazon_polly/ @jschlyter
/homeassistant/components/amberelectric/ @madpilot
/tests/components/amberelectric/ @madpilot
/homeassistant/components/ambient_network/ @thomaskistler
@@ -130,8 +129,6 @@ build.json @home-assistant/supervisor
/tests/components/aprs/ @PhilRW
/homeassistant/components/apsystems/ @mawoka-myblock @SonnenladenGmbH
/tests/components/apsystems/ @mawoka-myblock @SonnenladenGmbH
/homeassistant/components/aquacell/ @Jordi1990
/tests/components/aquacell/ @Jordi1990
/homeassistant/components/aranet/ @aschmitz @thecode @anrijs
/tests/components/aranet/ @aschmitz @thecode @anrijs
/homeassistant/components/arcam_fmj/ @elupus
@@ -187,8 +184,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/binary_sensor/ @home-assistant/core
/tests/components/binary_sensor/ @home-assistant/core
/homeassistant/components/bizkaibus/ @UgaitzEtxebarria
/homeassistant/components/blebox/ @bbx-a @swistakm
/tests/components/blebox/ @bbx-a @swistakm
/homeassistant/components/blebox/ @bbx-a @riokuu @swistakm
/tests/components/blebox/ @bbx-a @riokuu @swistakm
/homeassistant/components/blink/ @fronzbot @mkmer
/tests/components/blink/ @fronzbot @mkmer
/homeassistant/components/blue_current/ @Floris272 @gleeuwen
@@ -382,7 +379,7 @@ build.json @home-assistant/supervisor
/homeassistant/components/elvia/ @ludeeus
/tests/components/elvia/ @ludeeus
/homeassistant/components/emby/ @mezz64
/homeassistant/components/emoncms/ @borpin @alexandrecuer
/homeassistant/components/emoncms/ @borpin
/homeassistant/components/emonitor/ @bdraco
/tests/components/emonitor/ @bdraco
/homeassistant/components/emulated_hue/ @bdraco @Tho85
@@ -661,8 +658,7 @@ build.json @home-assistant/supervisor
/tests/components/imgw_pib/ @bieniu
/homeassistant/components/improv_ble/ @emontnemery
/tests/components/improv_ble/ @emontnemery
/homeassistant/components/incomfort/ @jbouwh
/tests/components/incomfort/ @jbouwh
/homeassistant/components/incomfort/ @zxdavb
/homeassistant/components/influxdb/ @mdegat01
/tests/components/influxdb/ @mdegat01
/homeassistant/components/inkbird/ @bdraco
@@ -706,8 +702,6 @@ build.json @home-assistant/supervisor
/tests/components/islamic_prayer_times/ @engrbm87 @cpfair
/homeassistant/components/iss/ @DurgNomis-drol
/tests/components/iss/ @DurgNomis-drol
/homeassistant/components/ista_ecotrend/ @tr4nt0r
/tests/components/ista_ecotrend/ @tr4nt0r
/homeassistant/components/isy994/ @bdraco @shbatm
/tests/components/isy994/ @bdraco @shbatm
/homeassistant/components/izone/ @Swamp-Ig
@@ -1492,6 +1486,8 @@ build.json @home-assistant/supervisor
/tests/components/unifi/ @Kane610
/homeassistant/components/unifi_direct/ @tofuSCHNITZEL
/homeassistant/components/unifiled/ @florisvdk
/homeassistant/components/unifiprotect/ @bdraco
/tests/components/unifiprotect/ @bdraco
/homeassistant/components/upb/ @gwww
/tests/components/upb/ @gwww
/homeassistant/components/upc_connect/ @pvizeli @fabaff
+5 -5
View File
@@ -1,10 +1,10 @@
image: ghcr.io/home-assistant/{arch}-homeassistant
build_from:
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.06.0
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.06.0
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.06.0
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.06.0
i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.06.0
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.03.0
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.03.0
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.03.0
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.03.0
i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.03.0
codenotary:
signer: notary@home-assistant.io
base_image: notary@home-assistant.io
+1 -1
View File
@@ -53,7 +53,7 @@ async def auth_manager_from_config(
) -> AuthManager:
"""Initialize an auth manager from config.
CORE_CONFIG_SCHEMA will make sure no duplicated auth providers or
CORE_CONFIG_SCHEMA will make sure do duplicated auth providers or
mfa modules exist in configs.
"""
store = auth_store.AuthStore(hass)
+2 -20
View File
@@ -2,10 +2,8 @@
import builtins
from contextlib import suppress
import glob
from http.client import HTTPConnection
import importlib
import os
import sys
import threading
import time
@@ -54,32 +52,16 @@ def enable() -> None:
HTTPConnection.putrequest, loop_thread_id=loop_thread_id
)
# Prevent sleeping in event loop.
# Prevent sleeping in event loop. Non-strict since 2022.02
time.sleep = protect_loop(
time.sleep,
strict=False,
check_allowed=_check_sleep_call_allowed,
loop_thread_id=loop_thread_id,
)
glob.glob = protect_loop(
glob.glob, strict_core=False, strict=False, loop_thread_id=loop_thread_id
)
glob.iglob = protect_loop(
glob.iglob, strict_core=False, strict=False, loop_thread_id=loop_thread_id
)
os.walk = protect_loop(
os.walk, strict_core=False, strict=False, loop_thread_id=loop_thread_id
)
if not _IN_TESTS:
# Prevent files being opened inside the event loop
os.listdir = protect_loop( # type: ignore[assignment]
os.listdir, strict_core=False, strict=False, loop_thread_id=loop_thread_id
)
os.scandir = protect_loop( # type: ignore[assignment]
os.scandir, strict_core=False, strict=False, loop_thread_id=loop_thread_id
)
builtins.open = protect_loop( # type: ignore[assignment]
builtins.open,
strict_core=False,
+3 -10
View File
@@ -134,15 +134,8 @@ COOLDOWN_TIME = 60
DEBUGGER_INTEGRATIONS = {"debugpy"}
# Core integrations are unconditionally loaded
CORE_INTEGRATIONS = {"homeassistant", "persistent_notification"}
# Integrations that are loaded right after the core is set up
LOGGING_AND_HTTP_DEPS_INTEGRATIONS = {
# isal is loaded right away before `http` to ensure if its
# enabled, that `isal` is up to date.
"isal",
LOGGING_INTEGRATIONS = {
# Set log levels
"logger",
# Error logging
@@ -221,8 +214,8 @@ CRITICAL_INTEGRATIONS = {
}
SETUP_ORDER = (
# Load logging and http deps as soon as possible
("logging, http deps", LOGGING_AND_HTTP_DEPS_INTEGRATIONS),
# Load logging as soon as possible
("logging", LOGGING_INTEGRATIONS),
# Setup frontend and recorder
("frontend, recorder", {*FRONTEND_INTEGRATIONS, *RECORDER_INTEGRATIONS}),
# Start up debuggers. Start these first in case they want to wait.
-5
View File
@@ -1,5 +0,0 @@
{
"domain": "ruuvi",
"name": "Ruuvi",
"integrations": ["ruuvi_gateway", "ruuvitag_ble"]
}
-5
View File
@@ -1,5 +0,0 @@
{
"domain": "weatherflow",
"name": "WeatherFlow",
"integrations": ["weatherflow", "weatherflow_cloud"]
}
@@ -1,51 +0,0 @@
{
"entity": {
"sensor": {
"cloud_ceiling": {
"default": "mdi:weather-fog"
},
"cloud_cover": {
"default": "mdi:weather-cloudy"
},
"cloud_cover_day": {
"default": "mdi:weather-cloudy"
},
"cloud_cover_night": {
"default": "mdi:weather-cloudy"
},
"grass_pollen": {
"default": "mdi:grass"
},
"hours_of_sun": {
"default": "mdi:weather-partly-cloudy"
},
"mold_pollen": {
"default": "mdi:blur"
},
"pressure_tendency": {
"default": "mdi:gauge"
},
"ragweed_pollen": {
"default": "mdi:sprout"
},
"thunderstorm_probability_day": {
"default": "mdi:weather-lightning"
},
"thunderstorm_probability_night": {
"default": "mdi:weather-lightning"
},
"translation_key": {
"default": "mdi:air-filter"
},
"tree_pollen": {
"default": "mdi:tree-outline"
},
"uv_index": {
"default": "mdi:weather-sunny"
},
"uv_index_forecast": {
"default": "mdi:weather-sunny"
}
}
}
}
+267 -156
View File
@@ -55,174 +55,284 @@ class AccuWeatherSensorDescription(SensorEntityDescription):
attr_fn: Callable[[dict[str, Any]], dict[str, Any]] = lambda _: {}
FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
AccuWeatherSensorDescription(
key="AirQuality",
value_fn=lambda data: cast(str, data[ATTR_CATEGORY]),
device_class=SensorDeviceClass.ENUM,
options=["good", "hazardous", "high", "low", "moderate", "unhealthy"],
translation_key="air_quality",
@dataclass(frozen=True, kw_only=True)
class AccuWeatherForecastSensorDescription(AccuWeatherSensorDescription):
"""Class describing AccuWeather sensor entities."""
day: int
FORECAST_SENSOR_TYPES: tuple[AccuWeatherForecastSensorDescription, ...] = (
*(
AccuWeatherForecastSensorDescription(
key="AirQuality",
icon="mdi:air-filter",
value_fn=lambda data: cast(str, data[ATTR_CATEGORY]),
device_class=SensorDeviceClass.ENUM,
options=["good", "hazardous", "high", "low", "moderate", "unhealthy"],
translation_key=f"air_quality_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
AccuWeatherSensorDescription(
key="CloudCoverDay",
entity_registry_enabled_default=False,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda data: cast(int, data),
translation_key="cloud_cover_day",
*(
AccuWeatherForecastSensorDescription(
key="CloudCoverDay",
icon="mdi:weather-cloudy",
entity_registry_enabled_default=False,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda data: cast(int, data),
translation_key=f"cloud_cover_day_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
AccuWeatherSensorDescription(
key="CloudCoverNight",
entity_registry_enabled_default=False,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda data: cast(int, data),
translation_key="cloud_cover_night",
*(
AccuWeatherForecastSensorDescription(
key="CloudCoverNight",
icon="mdi:weather-cloudy",
entity_registry_enabled_default=False,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda data: cast(int, data),
translation_key=f"cloud_cover_night_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
AccuWeatherSensorDescription(
key="Grass",
entity_registry_enabled_default=False,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
translation_key="grass_pollen",
*(
AccuWeatherForecastSensorDescription(
key="Grass",
icon="mdi:grass",
entity_registry_enabled_default=False,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
translation_key=f"grass_pollen_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
AccuWeatherSensorDescription(
key="HoursOfSun",
native_unit_of_measurement=UnitOfTime.HOURS,
value_fn=lambda data: cast(float, data),
translation_key="hours_of_sun",
*(
AccuWeatherForecastSensorDescription(
key="HoursOfSun",
icon="mdi:weather-partly-cloudy",
native_unit_of_measurement=UnitOfTime.HOURS,
value_fn=lambda data: cast(float, data),
translation_key=f"hours_of_sun_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
AccuWeatherSensorDescription(
key="LongPhraseDay",
value_fn=lambda data: cast(str, data),
translation_key="condition_day",
*(
AccuWeatherForecastSensorDescription(
key="LongPhraseDay",
value_fn=lambda data: cast(str, data),
translation_key=f"condition_day_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
AccuWeatherSensorDescription(
key="LongPhraseNight",
value_fn=lambda data: cast(str, data),
translation_key="condition_night",
*(
AccuWeatherForecastSensorDescription(
key="LongPhraseNight",
value_fn=lambda data: cast(str, data),
translation_key=f"condition_night_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
AccuWeatherSensorDescription(
key="Mold",
entity_registry_enabled_default=False,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
translation_key="mold_pollen",
*(
AccuWeatherForecastSensorDescription(
key="Mold",
icon="mdi:blur",
entity_registry_enabled_default=False,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
translation_key=f"mold_pollen_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
AccuWeatherSensorDescription(
key="Ragweed",
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
entity_registry_enabled_default=False,
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
translation_key="ragweed_pollen",
*(
AccuWeatherForecastSensorDescription(
key="Ragweed",
icon="mdi:sprout",
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
entity_registry_enabled_default=False,
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
translation_key=f"ragweed_pollen_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
AccuWeatherSensorDescription(
key="RealFeelTemperatureMax",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: cast(float, data[ATTR_VALUE]),
translation_key="realfeel_temperature_max",
*(
AccuWeatherForecastSensorDescription(
key="RealFeelTemperatureMax",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: cast(float, data[ATTR_VALUE]),
translation_key=f"realfeel_temperature_max_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
AccuWeatherSensorDescription(
key="RealFeelTemperatureMin",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: cast(float, data[ATTR_VALUE]),
translation_key="realfeel_temperature_min",
*(
AccuWeatherForecastSensorDescription(
key="RealFeelTemperatureMin",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: cast(float, data[ATTR_VALUE]),
translation_key=f"realfeel_temperature_min_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
AccuWeatherSensorDescription(
key="RealFeelTemperatureShadeMax",
device_class=SensorDeviceClass.TEMPERATURE,
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: cast(float, data[ATTR_VALUE]),
translation_key="realfeel_temperature_shade_max",
*(
AccuWeatherForecastSensorDescription(
key="RealFeelTemperatureShadeMax",
device_class=SensorDeviceClass.TEMPERATURE,
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: cast(float, data[ATTR_VALUE]),
translation_key=f"realfeel_temperature_shade_max_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
AccuWeatherSensorDescription(
key="RealFeelTemperatureShadeMin",
device_class=SensorDeviceClass.TEMPERATURE,
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: cast(float, data[ATTR_VALUE]),
translation_key="realfeel_temperature_shade_min",
*(
AccuWeatherForecastSensorDescription(
key="RealFeelTemperatureShadeMin",
device_class=SensorDeviceClass.TEMPERATURE,
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: cast(float, data[ATTR_VALUE]),
translation_key=f"realfeel_temperature_shade_min_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
AccuWeatherSensorDescription(
key="SolarIrradianceDay",
device_class=SensorDeviceClass.IRRADIANCE,
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER,
value_fn=lambda data: cast(float, data[ATTR_VALUE]),
translation_key="solar_irradiance_day",
*(
AccuWeatherForecastSensorDescription(
key="SolarIrradianceDay",
icon="mdi:weather-sunny",
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER,
value_fn=lambda data: cast(float, data[ATTR_VALUE]),
translation_key=f"solar_irradiance_day_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
AccuWeatherSensorDescription(
key="SolarIrradianceNight",
device_class=SensorDeviceClass.IRRADIANCE,
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER,
value_fn=lambda data: cast(float, data[ATTR_VALUE]),
translation_key="solar_irradiance_night",
*(
AccuWeatherForecastSensorDescription(
key="SolarIrradianceNight",
icon="mdi:weather-sunny",
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER,
value_fn=lambda data: cast(float, data[ATTR_VALUE]),
translation_key=f"solar_irradiance_night_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
AccuWeatherSensorDescription(
key="ThunderstormProbabilityDay",
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda data: cast(int, data),
translation_key="thunderstorm_probability_day",
*(
AccuWeatherForecastSensorDescription(
key="ThunderstormProbabilityDay",
icon="mdi:weather-lightning",
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda data: cast(int, data),
translation_key=f"thunderstorm_probability_day_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
AccuWeatherSensorDescription(
key="ThunderstormProbabilityNight",
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda data: cast(int, data),
translation_key="thunderstorm_probability_night",
*(
AccuWeatherForecastSensorDescription(
key="ThunderstormProbabilityNight",
icon="mdi:weather-lightning",
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda data: cast(int, data),
translation_key=f"thunderstorm_probability_night_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
AccuWeatherSensorDescription(
key="Tree",
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
entity_registry_enabled_default=False,
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
translation_key="tree_pollen",
*(
AccuWeatherForecastSensorDescription(
key="Tree",
icon="mdi:tree-outline",
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
entity_registry_enabled_default=False,
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
translation_key=f"tree_pollen_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
AccuWeatherSensorDescription(
key="UVIndex",
native_unit_of_measurement=UV_INDEX,
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
translation_key="uv_index_forecast",
*(
AccuWeatherForecastSensorDescription(
key="UVIndex",
icon="mdi:weather-sunny",
native_unit_of_measurement=UV_INDEX,
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
translation_key=f"uv_index_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
AccuWeatherSensorDescription(
key="WindGustDay",
device_class=SensorDeviceClass.WIND_SPEED,
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]),
attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]},
translation_key="wind_gust_speed_day",
*(
AccuWeatherForecastSensorDescription(
key="WindGustDay",
device_class=SensorDeviceClass.WIND_SPEED,
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]),
attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]},
translation_key=f"wind_gust_speed_day_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
AccuWeatherSensorDescription(
key="WindGustNight",
device_class=SensorDeviceClass.WIND_SPEED,
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]),
attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]},
translation_key="wind_gust_speed_night",
*(
AccuWeatherForecastSensorDescription(
key="WindGustNight",
device_class=SensorDeviceClass.WIND_SPEED,
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]),
attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]},
translation_key=f"wind_gust_speed_night_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
AccuWeatherSensorDescription(
key="WindDay",
device_class=SensorDeviceClass.WIND_SPEED,
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]),
attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]},
translation_key="wind_speed_day",
*(
AccuWeatherForecastSensorDescription(
key="WindDay",
device_class=SensorDeviceClass.WIND_SPEED,
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]),
attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]},
translation_key=f"wind_speed_day_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
AccuWeatherSensorDescription(
key="WindNight",
device_class=SensorDeviceClass.WIND_SPEED,
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]),
attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]},
translation_key="wind_speed_night",
*(
AccuWeatherForecastSensorDescription(
key="WindNight",
device_class=SensorDeviceClass.WIND_SPEED,
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]),
attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]},
translation_key=f"wind_speed_night_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
)
@@ -239,6 +349,7 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
AccuWeatherSensorDescription(
key="Ceiling",
device_class=SensorDeviceClass.DISTANCE,
icon="mdi:weather-fog",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfLength.METERS,
value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]),
@@ -247,6 +358,7 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
),
AccuWeatherSensorDescription(
key="CloudCover",
icon="mdi:weather-cloudy",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
@@ -291,12 +403,14 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
AccuWeatherSensorDescription(
key="PressureTendency",
device_class=SensorDeviceClass.ENUM,
icon="mdi:gauge",
options=["falling", "rising", "steady"],
value_fn=lambda data: cast(str, data["LocalizedText"]).lower(),
translation_key="pressure_tendency",
),
AccuWeatherSensorDescription(
key="UVIndex",
icon="mdi:weather-sunny",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UV_INDEX,
value_fn=lambda data: cast(int, data),
@@ -361,10 +475,9 @@ async def async_setup_entry(
sensors.extend(
[
AccuWeatherForecastSensor(forecast_daily_coordinator, description, day)
for day in range(MAX_FORECAST_DAYS + 1)
AccuWeatherForecastSensor(forecast_daily_coordinator, description)
for description in FORECAST_SENSOR_TYPES
if description.key in forecast_daily_coordinator.data[day]
if description.key in forecast_daily_coordinator.data[description.day]
]
)
@@ -430,27 +543,25 @@ class AccuWeatherForecastSensor(
_attr_attribution = ATTRIBUTION
_attr_has_entity_name = True
entity_description: AccuWeatherSensorDescription
entity_description: AccuWeatherForecastSensorDescription
def __init__(
self,
coordinator: AccuWeatherDailyForecastDataUpdateCoordinator,
description: AccuWeatherSensorDescription,
forecast_day: int,
description: AccuWeatherForecastSensorDescription,
) -> None:
"""Initialize."""
super().__init__(coordinator)
self.forecast_day = description.day
self.entity_description = description
self._sensor_data = self._get_sensor_data(
coordinator.data, description.key, forecast_day
coordinator.data, description.key, self.forecast_day
)
self._attr_unique_id = (
f"{coordinator.location_key}-{description.key}-{forecast_day}".lower()
f"{coordinator.location_key}-{description.key}-{self.forecast_day}".lower()
)
self._attr_device_info = coordinator.device_info
self._attr_translation_placeholders = {"forecast_day": str(forecast_day)}
self.forecast_day = forecast_day
@property
def native_value(self) -> str | int | float | None:
+656 -88
View File
@@ -21,8 +21,8 @@
},
"entity": {
"sensor": {
"air_quality": {
"name": "Air quality day {forecast_day}",
"air_quality_0d": {
"name": "Air quality today",
"state": {
"good": "Good",
"hazardous": "Hazardous",
@@ -32,6 +32,50 @@
"unhealthy": "Unhealthy"
}
},
"air_quality_1d": {
"name": "Air quality day 1",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
},
"air_quality_2d": {
"name": "Air quality day 2",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
},
"air_quality_3d": {
"name": "Air quality day 3",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
},
"air_quality_4d": {
"name": "Air quality day 4",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
},
"apparent_temperature": {
"name": "Apparent temperature"
},
@@ -41,52 +85,240 @@
"cloud_cover": {
"name": "Cloud cover"
},
"cloud_cover_day": {
"name": "Cloud cover day {forecast_day}"
"cloud_cover_day_0d": {
"name": "Cloud cover today"
},
"cloud_cover_night": {
"name": "Cloud cover night {forecast_day}"
"cloud_cover_day_1d": {
"name": "Cloud cover day 1"
},
"condition_day": {
"name": "Condition day {forecast_day}"
"cloud_cover_day_2d": {
"name": "Cloud cover day 2"
},
"condition_night": {
"name": "Condition night {forecast_day}"
"cloud_cover_day_3d": {
"name": "Cloud cover day 3"
},
"cloud_cover_day_4d": {
"name": "Cloud cover day 4"
},
"cloud_cover_night_0d": {
"name": "Cloud cover tonight"
},
"cloud_cover_night_1d": {
"name": "Cloud cover night 1"
},
"cloud_cover_night_2d": {
"name": "Cloud cover night 2"
},
"cloud_cover_night_3d": {
"name": "Cloud cover night 3"
},
"cloud_cover_night_4d": {
"name": "Cloud cover night 4"
},
"condition_day_0d": {
"name": "Condition today"
},
"condition_day_1d": {
"name": "Condition day 1"
},
"condition_day_2d": {
"name": "Condition day 2"
},
"condition_day_3d": {
"name": "Condition day 3"
},
"condition_day_4d": {
"name": "Condition day 4"
},
"condition_night_0d": {
"name": "Condition tonight"
},
"condition_night_1d": {
"name": "Condition night 1"
},
"condition_night_2d": {
"name": "Condition night 2"
},
"condition_night_3d": {
"name": "Condition night 3"
},
"condition_night_4d": {
"name": "Condition night 4"
},
"dew_point": {
"name": "Dew point"
},
"grass_pollen": {
"name": "Grass pollen day {forecast_day}",
"grass_pollen_0d": {
"name": "Grass pollen today",
"state_attributes": {
"level": {
"name": "Level",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]"
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"hours_of_sun": {
"name": "Hours of sun day {forecast_day}"
},
"mold_pollen": {
"name": "Mold pollen day {forecast_day}",
"grass_pollen_1d": {
"name": "Grass pollen day 1",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]"
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"grass_pollen_2d": {
"name": "Grass pollen day 2",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"grass_pollen_3d": {
"name": "Grass pollen day 3",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"grass_pollen_4d": {
"name": "Grass pollen day 4",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"hours_of_sun_0d": {
"name": "Hours of sun today"
},
"hours_of_sun_1d": {
"name": "Hours of sun day 1"
},
"hours_of_sun_2d": {
"name": "Hours of sun day 2"
},
"hours_of_sun_3d": {
"name": "Hours of sun day 3"
},
"hours_of_sun_4d": {
"name": "Hours of sun day 4"
},
"mold_pollen_0d": {
"name": "Mold pollen today",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"mold_pollen_1d": {
"name": "Mold pollen day 1",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"mold_pollen_2d": {
"name": "Mold pollen day 2",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"mold_pollen_3d": {
"name": "Mold pollen day 3",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"mold_pollen_4d": {
"name": "Mold pollen day 4",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
@@ -102,18 +334,82 @@
"falling": "Falling"
}
},
"ragweed_pollen": {
"name": "Ragweed pollen day {forecast_day}",
"ragweed_pollen_0d": {
"name": "Ragweed pollen today",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]"
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"ragweed_pollen_1d": {
"name": "Ragweed pollen day 1",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"ragweed_pollen_2d": {
"name": "Ragweed pollen day 2",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"ragweed_pollen_3d": {
"name": "Ragweed pollen day 3",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"ragweed_pollen_4d": {
"name": "Ragweed pollen day 4",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
@@ -121,45 +417,205 @@
"realfeel_temperature": {
"name": "RealFeel temperature"
},
"realfeel_temperature_max": {
"name": "RealFeel temperature max day {forecast_day}"
"realfeel_temperature_max_0d": {
"name": "RealFeel temperature max today"
},
"realfeel_temperature_min": {
"name": "RealFeel temperature min day {forecast_day}"
"realfeel_temperature_max_1d": {
"name": "RealFeel temperature max day 1"
},
"realfeel_temperature_max_2d": {
"name": "RealFeel temperature max day 2"
},
"realfeel_temperature_max_3d": {
"name": "RealFeel temperature max day 3"
},
"realfeel_temperature_max_4d": {
"name": "RealFeel temperature max day 4"
},
"realfeel_temperature_min_0d": {
"name": "RealFeel temperature min today"
},
"realfeel_temperature_min_1d": {
"name": "RealFeel temperature min day 1"
},
"realfeel_temperature_min_2d": {
"name": "RealFeel temperature min day 2"
},
"realfeel_temperature_min_3d": {
"name": "RealFeel temperature min day 3"
},
"realfeel_temperature_min_4d": {
"name": "RealFeel temperature min day 4"
},
"realfeel_temperature_shade": {
"name": "RealFeel temperature shade"
},
"realfeel_temperature_shade_max": {
"name": "RealFeel temperature shade max day {forecast_day}"
"realfeel_temperature_shade_max_0d": {
"name": "RealFeel temperature shade max today"
},
"realfeel_temperature_shade_min": {
"name": "RealFeel temperature shade min day {forecast_day}"
"realfeel_temperature_shade_max_1d": {
"name": "RealFeel temperature shade max day 1"
},
"solar_irradiance_day": {
"name": "Solar irradiance day {forecast_day}"
"realfeel_temperature_shade_max_2d": {
"name": "RealFeel temperature shade max day 2"
},
"solar_irradiance_night": {
"name": "Solar irradiance night {forecast_day}"
"realfeel_temperature_shade_max_3d": {
"name": "RealFeel temperature shade max day 3"
},
"thunderstorm_probability_day": {
"name": "Thunderstorm probability day {forecast_day}"
"realfeel_temperature_shade_max_4d": {
"name": "RealFeel temperature shade max day 4"
},
"thunderstorm_probability_night": {
"name": "Thunderstorm probability night {forecast_day}"
"realfeel_temperature_shade_min_0d": {
"name": "RealFeel temperature shade min today"
},
"tree_pollen": {
"name": "Tree pollen day {forecast_day}",
"realfeel_temperature_shade_min_1d": {
"name": "RealFeel temperature shade min day 1"
},
"realfeel_temperature_shade_min_2d": {
"name": "RealFeel temperature shade min day 2"
},
"realfeel_temperature_shade_min_3d": {
"name": "RealFeel temperature shade min day 3"
},
"realfeel_temperature_shade_min_4d": {
"name": "RealFeel temperature shade min day 4"
},
"solar_irradiance_day_0d": {
"name": "Solar irradiance today"
},
"solar_irradiance_day_1d": {
"name": "Solar irradiance day 1"
},
"solar_irradiance_day_2d": {
"name": "Solar irradiance day 2"
},
"solar_irradiance_day_3d": {
"name": "Solar irradiance day 3"
},
"solar_irradiance_day_4d": {
"name": "Solar irradiance day 4"
},
"solar_irradiance_night_0d": {
"name": "Solar irradiance tonight"
},
"solar_irradiance_night_1d": {
"name": "Solar irradiance night 1"
},
"solar_irradiance_night_2d": {
"name": "Solar irradiance night 2"
},
"solar_irradiance_night_3d": {
"name": "Solar irradiance night 3"
},
"solar_irradiance_night_4d": {
"name": "Solar irradiance night 4"
},
"thunderstorm_probability_day_0d": {
"name": "Thunderstorm probability today"
},
"thunderstorm_probability_day_1d": {
"name": "Thunderstorm probability day 1"
},
"thunderstorm_probability_day_2d": {
"name": "Thunderstorm probability day 2"
},
"thunderstorm_probability_day_3d": {
"name": "Thunderstorm probability day 3"
},
"thunderstorm_probability_day_4d": {
"name": "Thunderstorm probability day 4"
},
"thunderstorm_probability_night_0d": {
"name": "Thunderstorm probability tonight"
},
"thunderstorm_probability_night_1d": {
"name": "Thunderstorm probability night 1"
},
"thunderstorm_probability_night_2d": {
"name": "Thunderstorm probability night 2"
},
"thunderstorm_probability_night_3d": {
"name": "Thunderstorm probability night 3"
},
"thunderstorm_probability_night_4d": {
"name": "Thunderstorm probability night 4"
},
"tree_pollen_0d": {
"name": "Tree pollen today",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]"
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"tree_pollen_1d": {
"name": "Tree pollen day 1",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"tree_pollen_2d": {
"name": "Tree pollen day 2",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"tree_pollen_3d": {
"name": "Tree pollen day 3",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"tree_pollen_4d": {
"name": "Tree pollen day 4",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
@@ -168,30 +624,94 @@
"name": "UV index",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]"
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"uv_index_forecast": {
"name": "UV index day {forecast_day}",
"uv_index_0d": {
"name": "UV index today",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]"
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"uv_index_1d": {
"name": "UV index day 1",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"uv_index_2d": {
"name": "UV index day 2",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"uv_index_3d": {
"name": "UV index day 3",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"uv_index_4d": {
"name": "UV index day 4",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
@@ -208,17 +728,65 @@
"wind_gust_speed": {
"name": "[%key:component::weather::entity_component::_::state_attributes::wind_gust_speed::name%]"
},
"wind_gust_speed_day": {
"name": "Wind gust speed day {forecast_day}"
"wind_gust_speed_day_0d": {
"name": "Wind gust speed today"
},
"wind_gust_speed_night": {
"name": "Wind gust speed night {forecast_day}"
"wind_gust_speed_day_1d": {
"name": "Wind gust speed day 1"
},
"wind_speed_day": {
"name": "Wind speed day {forecast_day}"
"wind_gust_speed_day_2d": {
"name": "Wind gust speed day 2"
},
"wind_speed_night": {
"name": "Wind speed night {forecast_day}"
"wind_gust_speed_day_3d": {
"name": "Wind gust speed day 3"
},
"wind_gust_speed_day_4d": {
"name": "Wind gust speed day 4"
},
"wind_gust_speed_night_0d": {
"name": "Wind gust speed tonight"
},
"wind_gust_speed_night_1d": {
"name": "Wind gust speed night 1"
},
"wind_gust_speed_night_2d": {
"name": "Wind gust speed night 2"
},
"wind_gust_speed_night_3d": {
"name": "Wind gust speed night 3"
},
"wind_gust_speed_night_4d": {
"name": "Wind gust speed night 4"
},
"wind_speed_day_0d": {
"name": "Wind speed today"
},
"wind_speed_day_1d": {
"name": "Wind speed day 1"
},
"wind_speed_day_2d": {
"name": "Wind speed day 2"
},
"wind_speed_day_3d": {
"name": "Wind speed day 3"
},
"wind_speed_day_4d": {
"name": "Wind speed day 4"
},
"wind_speed_night_0d": {
"name": "Wind speed tonight"
},
"wind_speed_night_1d": {
"name": "Wind speed night 1"
},
"wind_speed_night_2d": {
"name": "Wind speed night 2"
},
"wind_speed_night_3d": {
"name": "Wind speed night 3"
},
"wind_speed_night_4d": {
"name": "Wind speed night 4"
}
}
},
@@ -7,7 +7,6 @@ from typing import cast
from homeassistant.components.weather import (
ATTR_FORECAST_CLOUD_COVERAGE,
ATTR_FORECAST_CONDITION,
ATTR_FORECAST_HUMIDITY,
ATTR_FORECAST_NATIVE_APPARENT_TEMP,
ATTR_FORECAST_NATIVE_PRECIPITATION,
ATTR_FORECAST_NATIVE_TEMP,
@@ -184,7 +183,6 @@ class AccuWeatherEntity(
{
ATTR_FORECAST_TIME: utc_from_timestamp(item["EpochDate"]).isoformat(),
ATTR_FORECAST_CLOUD_COVERAGE: item["CloudCoverDay"],
ATTR_FORECAST_HUMIDITY: item["RelativeHumidityDay"]["Average"],
ATTR_FORECAST_NATIVE_TEMP: item["TemperatureMax"][ATTR_VALUE],
ATTR_FORECAST_NATIVE_TEMP_LOW: item["TemperatureMin"][ATTR_VALUE],
ATTR_FORECAST_NATIVE_APPARENT_TEMP: item["RealFeelTemperatureMax"][
+1 -1
View File
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/ads",
"iot_class": "local_push",
"loggers": ["pyads"],
"requirements": ["pyads==3.4.0"]
"requirements": ["pyads==3.2.2"]
}
@@ -43,7 +43,6 @@ class AgentBaseStation(AlarmControlPanelEntity):
| AlarmControlPanelEntityFeature.ARM_AWAY
| AlarmControlPanelEntityFeature.ARM_NIGHT
)
_attr_code_arm_required = False
_attr_has_entity_name = True
_attr_name = None
@@ -2,8 +2,6 @@
from __future__ import annotations
from dataclasses import dataclass
from airgradient import AirGradientClient
from homeassistant.config_entries import ConfigEntry
@@ -18,17 +16,6 @@ from .coordinator import AirGradientConfigCoordinator, AirGradientMeasurementCoo
PLATFORMS: list[Platform] = [Platform.SELECT, Platform.SENSOR]
@dataclass
class AirGradientData:
"""AirGradient data class."""
measurement: AirGradientMeasurementCoordinator
config: AirGradientConfigCoordinator
type AirGradientConfigEntry = ConfigEntry[AirGradientData]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Airgradient from a config entry."""
@@ -52,10 +39,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
sw_version=measurement_coordinator.data.firmware_version,
)
entry.runtime_data = AirGradientData(
measurement=measurement_coordinator,
config=config_coordinator,
)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
"measurement": measurement_coordinator,
"config": config_coordinator,
}
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -64,4 +51,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
@@ -2,9 +2,7 @@
from typing import Any
from airgradient import AirGradientClient, AirGradientError, ConfigurationControl
from awesomeversion import AwesomeVersion
from mashumaro import MissingField
from airgradient import AirGradientClient, AirGradientError
import voluptuous as vol
from homeassistant.components import zeroconf
@@ -14,8 +12,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
MIN_VERSION = AwesomeVersion("3.1.1")
class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN):
"""AirGradient config flow."""
@@ -23,14 +19,6 @@ class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Initialize the config flow."""
self.data: dict[str, Any] = {}
self.client: AirGradientClient | None = None
async def set_configuration_source(self) -> None:
"""Set configuration source to local if it hasn't been set yet."""
assert self.client
config = await self.client.get_config()
if config.configuration_control is ConfigurationControl.NOT_INITIALIZED:
await self.client.set_configuration_control(ConfigurationControl.LOCAL)
async def async_step_zeroconf(
self, discovery_info: zeroconf.ZeroconfServiceInfo
@@ -42,12 +30,9 @@ class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(discovery_info.properties["serialno"])
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
if AwesomeVersion(discovery_info.properties["fw_ver"]) < MIN_VERSION:
return self.async_abort(reason="invalid_version")
session = async_get_clientsession(self.hass)
self.client = AirGradientClient(host, session=session)
await self.client.get_current_measures()
air_gradient = AirGradientClient(host, session=session)
await air_gradient.get_current_measures()
self.context["title_placeholders"] = {
"model": self.data[CONF_MODEL],
@@ -59,7 +44,6 @@ class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Confirm discovery."""
if user_input is not None:
await self.set_configuration_source()
return self.async_create_entry(
title=self.data[CONF_MODEL],
data={CONF_HOST: self.data[CONF_HOST]},
@@ -80,17 +64,14 @@ class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {}
if user_input:
session = async_get_clientsession(self.hass)
self.client = AirGradientClient(user_input[CONF_HOST], session=session)
air_gradient = AirGradientClient(user_input[CONF_HOST], session=session)
try:
current_measures = await self.client.get_current_measures()
current_measures = await air_gradient.get_current_measures()
except AirGradientError:
errors["base"] = "cannot_connect"
except MissingField:
return self.async_abort(reason="invalid_version")
else:
await self.async_set_unique_id(current_measures.serial_number)
self._abort_if_unique_id_configured()
await self.set_configuration_source()
return self.async_create_entry(
title=current_measures.model,
data={CONF_HOST: user_input[CONF_HOST]},
@@ -1,26 +1,21 @@
"""Define an object to manage fetching AirGradient data."""
from __future__ import annotations
from datetime import timedelta
from typing import TYPE_CHECKING
from airgradient import AirGradientClient, AirGradientError, Config, Measures
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import LOGGER
if TYPE_CHECKING:
from . import AirGradientConfigEntry
class AirGradientCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
"""Class to manage fetching AirGradient data."""
_update_interval: timedelta
config_entry: AirGradientConfigEntry
config_entry: ConfigEntry
def __init__(self, hass: HomeAssistant, client: AirGradientClient) -> None:
"""Initialize coordinator."""
@@ -1,11 +1,11 @@
{
"domain": "airgradient",
"name": "AirGradient",
"name": "Airgradient",
"codeowners": ["@airgradienthq", "@joostlek"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/airgradient",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["airgradient==0.4.3"],
"requirements": ["airgradient==0.4.2"],
"zeroconf": ["_airgradient._tcp.local."]
}
+13 -13
View File
@@ -7,14 +7,14 @@ from airgradient import AirGradientClient, Config
from airgradient.models import ConfigurationControl, TemperatureUnit
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AirGradientConfigEntry
from .const import DOMAIN
from .coordinator import AirGradientConfigCoordinator
from .coordinator import AirGradientConfigCoordinator, AirGradientMeasurementCoordinator
from .entity import AirGradientEntity
@@ -22,7 +22,7 @@ from .entity import AirGradientEntity
class AirGradientSelectEntityDescription(SelectEntityDescription):
"""Describes AirGradient select entity."""
value_fn: Callable[[Config], str | None]
value_fn: Callable[[Config], str]
set_value_fn: Callable[[AirGradientClient, str], Awaitable[None]]
requires_display: bool = False
@@ -30,11 +30,9 @@ class AirGradientSelectEntityDescription(SelectEntityDescription):
CONFIG_CONTROL_ENTITY = AirGradientSelectEntityDescription(
key="configuration_control",
translation_key="configuration_control",
options=[ConfigurationControl.CLOUD.value, ConfigurationControl.LOCAL.value],
options=[x.value for x in ConfigurationControl],
entity_category=EntityCategory.CONFIG,
value_fn=lambda config: config.configuration_control
if config.configuration_control is not ConfigurationControl.NOT_INITIALIZED
else None,
value_fn=lambda config: config.configuration_control,
set_value_fn=lambda client, value: client.set_configuration_control(
ConfigurationControl(value)
),
@@ -56,14 +54,16 @@ PROTECTED_SELECT_TYPES: tuple[AirGradientSelectEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: AirGradientConfigEntry,
async_add_entities: AddEntitiesCallback,
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up AirGradient select entities based on a config entry."""
config_coordinator = entry.runtime_data.config
measurement_coordinator = entry.runtime_data.measurement
config_coordinator: AirGradientConfigCoordinator = hass.data[DOMAIN][
entry.entry_id
]["config"]
measurement_coordinator: AirGradientMeasurementCoordinator = hass.data[DOMAIN][
entry.entry_id
]["measurement"]
entities = [AirGradientSelect(config_coordinator, CONFIG_CONTROL_ENTITY)]
@@ -96,7 +96,7 @@ class AirGradientSelect(AirGradientEntity, SelectEntity):
self._attr_unique_id = f"{coordinator.serial_number}-{description.key}"
@property
def current_option(self) -> str | None:
def current_option(self) -> str:
"""Return the state of the select."""
return self.entity_description.value_fn(self.coordinator.data)
@@ -11,6 +11,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_MILLION,
@@ -23,7 +24,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import AirGradientConfigEntry
from .const import DOMAIN
from .coordinator import AirGradientMeasurementCoordinator
from .entity import AirGradientEntity
@@ -102,7 +103,6 @@ SENSOR_TYPES: tuple[AirGradientSensorEntityDescription, ...] = (
AirGradientSensorEntityDescription(
key="pm003",
translation_key="pm003_count",
native_unit_of_measurement="particles/dL",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.pm003_count,
),
@@ -126,13 +126,13 @@ SENSOR_TYPES: tuple[AirGradientSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: AirGradientConfigEntry,
async_add_entities: AddEntitiesCallback,
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up AirGradient sensor entities based on a config entry."""
coordinator = entry.runtime_data.measurement
coordinator: AirGradientMeasurementCoordinator = hass.data[DOMAIN][entry.entry_id][
"measurement"
]
listener: Callable[[], None] | None = None
not_setup: set[AirGradientSensorEntityDescription] = set(SENSOR_TYPES)
@@ -15,8 +15,7 @@
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"invalid_version": "This firmware version is unsupported. Please upgrade the firmware of the device to at least version 3.1.1."
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -29,7 +28,8 @@
"name": "Configuration source",
"state": {
"cloud": "Cloud",
"local": "Local"
"local": "Local",
"both": "Both"
}
},
"display_temperature_unit": {
@@ -48,7 +48,7 @@
"name": "Nitrogen index"
},
"pm003_count": {
"name": "PM0.3"
"name": "PM0.3 count"
},
"raw_total_volatile_organic_component": {
"name": "Raw total VOC"
+11 -6
View File
@@ -7,15 +7,15 @@ from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DOMAIN
from .coordinator import AirtouchDataUpdateCoordinator
PLATFORMS = [Platform.CLIMATE]
type AirTouch4ConfigEntry = ConfigEntry[AirtouchDataUpdateCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: AirTouch4ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up AirTouch4 from a config entry."""
hass.data.setdefault(DOMAIN, {})
host = entry.data[CONF_HOST]
airtouch = AirTouch(host)
await airtouch.UpdateInfo()
@@ -24,13 +24,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirTouch4ConfigEntry) ->
raise ConfigEntryNotReady
coordinator = AirtouchDataUpdateCoordinator(hass, airtouch)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
hass.data[DOMAIN][entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: AirTouch4ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
@@ -16,13 +16,13 @@ from homeassistant.components.climate import (
ClimateEntityFeature,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import AirTouch4ConfigEntry
from .const import DOMAIN
AT_TO_HA_STATE = {
@@ -63,11 +63,11 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AirTouch4ConfigEntry,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Airtouch 4."""
coordinator = config_entry.runtime_data
coordinator = hass.data[DOMAIN][config_entry.entry_id]
info = coordinator.data
entities: list[ClimateEntity] = [
AirtouchGroup(coordinator, group["group_number"], info)
+9 -7
View File
@@ -17,6 +17,7 @@ from homeassistant.helpers import (
entity_registry as er,
)
from .const import DOMAIN
from .coordinator import AirzoneUpdateCoordinator
PLATFORMS: list[Platform] = [
@@ -29,12 +30,10 @@ PLATFORMS: list[Platform] = [
_LOGGER = logging.getLogger(__name__)
type AirzoneConfigEntry = ConfigEntry[AirzoneUpdateCoordinator]
async def _async_migrate_unique_ids(
hass: HomeAssistant,
entry: AirzoneConfigEntry,
entry: ConfigEntry,
coordinator: AirzoneUpdateCoordinator,
) -> None:
"""Migrate entities when the mac address gets discovered."""
@@ -72,7 +71,7 @@ async def _async_migrate_unique_ids(
await er.async_migrate_entries(hass, entry.entry_id, _async_migrator)
async def async_setup_entry(hass: HomeAssistant, entry: AirzoneConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Airzone from a config entry."""
options = ConnectionOptions(
entry.data[CONF_HOST],
@@ -85,13 +84,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirzoneConfigEntry) -> b
await coordinator.async_config_entry_first_refresh()
await _async_migrate_unique_ids(hass, entry, coordinator)
entry.runtime_data = coordinator
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: AirzoneConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
@@ -25,7 +25,7 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AirzoneConfigEntry
from .const import DOMAIN
from .coordinator import AirzoneUpdateCoordinator
from .entity import AirzoneEntity, AirzoneSystemEntity, AirzoneZoneEntity
@@ -75,12 +75,10 @@ ZONE_BINARY_SENSOR_TYPES: Final[tuple[AirzoneBinarySensorEntityDescription, ...]
async def async_setup_entry(
hass: HomeAssistant,
entry: AirzoneConfigEntry,
async_add_entities: AddEntitiesCallback,
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Add Airzone binary sensors from a config_entry."""
coordinator = entry.runtime_data
coordinator = hass.data[DOMAIN][entry.entry_id]
binary_sensors: list[AirzoneBinarySensor] = [
AirzoneSystemBinarySensor(
+3 -6
View File
@@ -50,8 +50,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AirzoneConfigEntry
from .const import API_TEMPERATURE_STEP, TEMP_UNIT_LIB_TO_HASS
from .const import API_TEMPERATURE_STEP, DOMAIN, TEMP_UNIT_LIB_TO_HASS
from .coordinator import AirzoneUpdateCoordinator
from .entity import AirzoneZoneEntity
@@ -98,12 +97,10 @@ HVAC_MODE_HASS_TO_LIB: Final[dict[HVACMode, OperationMode]] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: AirzoneConfigEntry,
async_add_entities: AddEntitiesCallback,
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Add Airzone sensors from a config_entry."""
coordinator = entry.runtime_data
coordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
AirzoneClimate(
coordinator,
@@ -7,10 +7,12 @@ from typing import Any
from aioairzone.const import API_MAC, AZD_MAC
from homeassistant.components.diagnostics.util import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_UNIQUE_ID
from homeassistant.core import HomeAssistant
from . import AirzoneConfigEntry
from .const import DOMAIN
from .coordinator import AirzoneUpdateCoordinator
TO_REDACT_API = [
API_MAC,
@@ -26,10 +28,10 @@ TO_REDACT_COORD = [
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: AirzoneConfigEntry
hass: HomeAssistant, config_entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = config_entry.runtime_data
coordinator: AirzoneUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
return {
"api_data": async_redact_data(coordinator.airzone.raw_data(), TO_REDACT_API),
+1 -2
View File
@@ -31,7 +31,6 @@ from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import AirzoneConfigEntry
from .const import DOMAIN, MANUFACTURER
from .coordinator import AirzoneUpdateCoordinator
@@ -54,7 +53,7 @@ class AirzoneSystemEntity(AirzoneEntity):
def __init__(
self,
coordinator: AirzoneUpdateCoordinator,
entry: AirzoneConfigEntry,
entry: ConfigEntry,
system_data: dict[str, Any],
) -> None:
"""Initialize."""
+3 -5
View File
@@ -22,7 +22,7 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AirzoneConfigEntry
from .const import DOMAIN
from .coordinator import AirzoneUpdateCoordinator
from .entity import AirzoneEntity, AirzoneZoneEntity
@@ -79,12 +79,10 @@ ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: AirzoneConfigEntry,
async_add_entities: AddEntitiesCallback,
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Add Airzone sensors from a config_entry."""
coordinator = entry.runtime_data
coordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
AirzoneZoneSelect(
+3 -6
View File
@@ -30,8 +30,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AirzoneConfigEntry
from .const import TEMP_UNIT_LIB_TO_HASS
from .const import DOMAIN, TEMP_UNIT_LIB_TO_HASS
from .coordinator import AirzoneUpdateCoordinator
from .entity import (
AirzoneEntity,
@@ -78,12 +77,10 @@ ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: AirzoneConfigEntry,
async_add_entities: AddEntitiesCallback,
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Add Airzone sensors from a config_entry."""
coordinator = entry.runtime_data
coordinator = hass.data[DOMAIN][entry.entry_id]
sensors: list[AirzoneSensor] = [
AirzoneZoneSensor(
@@ -30,8 +30,7 @@ from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AirzoneConfigEntry
from .const import TEMP_UNIT_LIB_TO_HASS
from .const import DOMAIN, TEMP_UNIT_LIB_TO_HASS
from .coordinator import AirzoneUpdateCoordinator
from .entity import AirzoneHotWaterEntity
@@ -57,12 +56,10 @@ OPERATION_MODE_TO_DHW_PARAMS: Final[dict[str, dict[str, Any]]] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: AirzoneConfigEntry,
async_add_entities: AddEntitiesCallback,
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Add Airzone sensors from a config_entry."""
coordinator = entry.runtime_data
coordinator = hass.data[DOMAIN][entry.entry_id]
if AZD_HOT_WATER in coordinator.data:
async_add_entities([AirzoneWaterHeater(coordinator, entry)])
@@ -10,6 +10,7 @@ from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
from .const import DOMAIN
from .coordinator import AirzoneUpdateCoordinator
PLATFORMS: list[Platform] = [
@@ -20,12 +21,8 @@ PLATFORMS: list[Platform] = [
Platform.WATER_HEATER,
]
type AirzoneCloudConfigEntry = ConfigEntry[AirzoneUpdateCoordinator]
async def async_setup_entry(
hass: HomeAssistant, entry: AirzoneCloudConfigEntry
) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Airzone Cloud from a config entry."""
options = ConnectionOptions(
entry.data[CONF_USERNAME],
@@ -44,20 +41,18 @@ async def async_setup_entry(
coordinator = AirzoneUpdateCoordinator(hass, airzone)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: AirzoneCloudConfigEntry
) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
coordinator = entry.runtime_data
coordinator: AirzoneUpdateCoordinator = hass.data[DOMAIN].pop(entry.entry_id)
await coordinator.airzone.logout()
return unload_ok
@@ -21,11 +21,12 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AirzoneCloudConfigEntry
from .const import DOMAIN
from .coordinator import AirzoneUpdateCoordinator
from .entity import (
AirzoneAidooEntity,
@@ -93,12 +94,10 @@ ZONE_BINARY_SENSOR_TYPES: Final[tuple[AirzoneBinarySensorEntityDescription, ...]
async def async_setup_entry(
hass: HomeAssistant,
entry: AirzoneCloudConfigEntry,
async_add_entities: AddEntitiesCallback,
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Add Airzone Cloud binary sensors from a config_entry."""
coordinator = entry.runtime_data
coordinator: AirzoneUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
binary_sensors: list[AirzoneBinarySensor] = [
AirzoneAidooBinarySensor(
@@ -53,12 +53,13 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AirzoneCloudConfigEntry
from .const import DOMAIN
from .coordinator import AirzoneUpdateCoordinator
from .entity import (
AirzoneAidooEntity,
@@ -118,12 +119,10 @@ HVAC_MODE_HASS_TO_LIB: Final[dict[HVACMode, OperationMode]] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: AirzoneCloudConfigEntry,
async_add_entities: AddEntitiesCallback,
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Add Airzone climate from a config_entry."""
coordinator = entry.runtime_data
coordinator: AirzoneUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
entities: list[AirzoneClimate] = []
@@ -194,12 +193,6 @@ class AirzoneClimate(AirzoneEntity, ClimateEntity):
ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
)
if (
self.get_airzone_value(AZD_SPEED) is not None
and self.get_airzone_value(AZD_SPEEDS) is not None
):
self._initialize_fan_speeds()
@callback
def _handle_coordinator_update(self) -> None:
"""Update attributes when the coordinator updates."""
@@ -214,8 +207,6 @@ class AirzoneClimate(AirzoneEntity, ClimateEntity):
self._attr_hvac_action = HVAC_ACTION_LIB_TO_HASS[
self.get_airzone_value(AZD_ACTION)
]
if self.supported_features & ClimateEntityFeature.FAN_MODE:
self._attr_fan_mode = self._speeds.get(self.get_airzone_value(AZD_SPEED))
if self.get_airzone_value(AZD_POWER):
self._attr_hvac_mode = HVAC_MODE_LIB_TO_HASS[
self.get_airzone_value(AZD_MODE)
@@ -243,37 +234,6 @@ class AirzoneDeviceClimate(AirzoneClimate):
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
)
_speeds: dict[int, str]
_speeds_reverse: dict[str, int]
def _initialize_fan_speeds(self) -> None:
"""Initialize fan speeds."""
azd_speeds: dict[int, int] = self.get_airzone_value(AZD_SPEEDS)
max_speed = max(azd_speeds)
fan_speeds: dict[int, str]
if speeds_map := FAN_SPEED_MAPS.get(max_speed):
fan_speeds = speeds_map
else:
fan_speeds = {}
for speed in azd_speeds:
if speed != 0:
fan_speeds[speed] = f"{int(round((speed * 100) / max_speed, 0))}%"
if 0 in azd_speeds:
fan_speeds = FAN_SPEED_AUTO | fan_speeds
self._speeds = {}
for key, value in fan_speeds.items():
_key = azd_speeds.get(key)
if _key is not None:
self._speeds[_key] = value
self._speeds_reverse = {v: k for k, v in self._speeds.items()}
self._attr_fan_modes = list(self._speeds_reverse)
self._attr_supported_features |= ClimateEntityFeature.FAN_MODE
async def async_turn_on(self) -> None:
"""Turn the entity on."""
@@ -293,15 +253,6 @@ class AirzoneDeviceClimate(AirzoneClimate):
}
await self._async_update_params(params)
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new fan mode."""
params: dict[str, Any] = {
API_SPEED_CONF: {
API_VALUE: self._speeds_reverse.get(fan_mode),
}
}
await self._async_update_params(params)
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
params: dict[str, Any] = {}
@@ -390,6 +341,9 @@ class AirzoneDeviceGroupClimate(AirzoneClimate):
class AirzoneAidooClimate(AirzoneAidooEntity, AirzoneDeviceClimate):
"""Define an Airzone Cloud Aidoo climate."""
_speeds: dict[int, str]
_speeds_reverse: dict[str, int]
def __init__(
self,
coordinator: AirzoneUpdateCoordinator,
@@ -401,9 +355,52 @@ class AirzoneAidooClimate(AirzoneAidooEntity, AirzoneDeviceClimate):
self._attr_unique_id = aidoo_id
self._init_attributes()
if (
self.get_airzone_value(AZD_SPEED) is not None
and self.get_airzone_value(AZD_SPEEDS) is not None
):
self._initialize_fan_speeds()
self._async_update_attrs()
def _initialize_fan_speeds(self) -> None:
"""Initialize Aidoo fan speeds."""
azd_speeds: dict[int, int] = self.get_airzone_value(AZD_SPEEDS)
max_speed = max(azd_speeds)
fan_speeds: dict[int, str]
if speeds_map := FAN_SPEED_MAPS.get(max_speed):
fan_speeds = speeds_map
else:
fan_speeds = {}
for speed in azd_speeds:
if speed != 0:
fan_speeds[speed] = f"{int(round((speed * 100) / max_speed, 0))}%"
if 0 in azd_speeds:
fan_speeds = FAN_SPEED_AUTO | fan_speeds
self._speeds = {}
for key, value in fan_speeds.items():
_key = azd_speeds.get(key)
if _key is not None:
self._speeds[_key] = value
self._speeds_reverse = {v: k for k, v in self._speeds.items()}
self._attr_fan_modes = list(self._speeds_reverse)
self._attr_supported_features |= ClimateEntityFeature.FAN_MODE
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set Aidoo fan mode."""
params: dict[str, Any] = {
API_SPEED_CONF: {
API_VALUE: self._speeds_reverse.get(fan_mode),
}
}
await self._async_update_params(params)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set hvac mode."""
params: dict[str, Any] = {}
@@ -421,6 +418,14 @@ class AirzoneAidooClimate(AirzoneAidooEntity, AirzoneDeviceClimate):
}
await self._async_update_params(params)
@callback
def _async_update_attrs(self) -> None:
"""Update Aidoo climate attributes."""
super()._async_update_attrs()
if self.supported_features & ClimateEntityFeature.FAN_MODE:
self._attr_fan_mode = self._speeds.get(self.get_airzone_value(AZD_SPEED))
class AirzoneGroupClimate(AirzoneGroupEntity, AirzoneDeviceGroupClimate):
"""Define an Airzone Cloud Group climate."""
@@ -22,10 +22,12 @@ from aioairzone_cloud.const import (
)
from homeassistant.components.diagnostics.util import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from . import AirzoneCloudConfigEntry
from .const import DOMAIN
from .coordinator import AirzoneUpdateCoordinator
TO_REDACT_API = [
API_CITY,
@@ -135,10 +137,10 @@ def redact_all(
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: AirzoneCloudConfigEntry
hass: HomeAssistant, config_entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = config_entry.runtime_data
coordinator: AirzoneUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
raw_data = coordinator.airzone.raw_data()
ids = gather_ids(raw_data)
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
"iot_class": "cloud_push",
"loggers": ["aioairzone_cloud"],
"requirements": ["aioairzone-cloud==0.5.2"]
"requirements": ["aioairzone-cloud==0.5.1"]
}
@@ -14,11 +14,12 @@ from aioairzone_cloud.const import (
)
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AirzoneCloudConfigEntry
from .const import DOMAIN
from .coordinator import AirzoneUpdateCoordinator
from .entity import AirzoneEntity, AirzoneZoneEntity
@@ -51,12 +52,10 @@ ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: AirzoneCloudConfigEntry,
async_add_entities: AddEntitiesCallback,
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Add Airzone Cloud select from a config_entry."""
coordinator = entry.runtime_data
coordinator: AirzoneUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
# Zones
async_add_entities(
@@ -23,6 +23,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
PERCENTAGE,
@@ -33,7 +34,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AirzoneCloudConfigEntry
from .const import DOMAIN
from .coordinator import AirzoneUpdateCoordinator
from .entity import (
AirzoneAidooEntity,
@@ -102,12 +103,10 @@ ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: AirzoneCloudConfigEntry,
async_add_entities: AddEntitiesCallback,
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Add Airzone Cloud sensors from a config_entry."""
coordinator = entry.runtime_data
coordinator: AirzoneUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
# Aidoos
sensors: list[AirzoneSensor] = [
@@ -27,11 +27,12 @@ from homeassistant.components.water_heater import (
WaterHeaterEntity,
WaterHeaterEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AirzoneCloudConfigEntry
from .const import DOMAIN
from .coordinator import AirzoneUpdateCoordinator
from .entity import AirzoneHotWaterEntity
@@ -67,12 +68,10 @@ OPERATION_MODE_TO_DHW_PARAMS: Final[dict[str, dict[str, Any]]] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: AirzoneCloudConfigEntry,
async_add_entities: AddEntitiesCallback,
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Add Airzone Cloud Water Heater from a config_entry."""
coordinator = entry.runtime_data
coordinator: AirzoneUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
AirzoneWaterHeater(
@@ -2,93 +2,52 @@
from __future__ import annotations
from genie_partner_sdk.client import AladdinConnectClient
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import (
OAuth2Session,
async_get_config_entry_implementation,
)
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
from .api import AsyncConfigEntryAuth
from .const import DOMAIN
from .coordinator import AladdinConnectCoordinator
from . import api
from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION
PLATFORMS: list[Platform] = [Platform.COVER, Platform.SENSOR]
type AladdinConnectConfigEntry = ConfigEntry[AladdinConnectCoordinator]
PLATFORMS: list[Platform] = [Platform.COVER]
async def async_setup_entry(
hass: HomeAssistant, entry: AladdinConnectConfigEntry
) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Aladdin Connect Genie from a config entry."""
implementation = await async_get_config_entry_implementation(hass, entry)
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
)
session = OAuth2Session(hass, entry, implementation)
auth = AsyncConfigEntryAuth(async_get_clientsession(hass), session)
coordinator = AladdinConnectCoordinator(hass, AladdinConnectClient(auth))
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
await coordinator.async_setup()
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
# If using an aiohttp-based API lib
entry.runtime_data = api.AsyncConfigEntryAuth(
aiohttp_client.async_get_clientsession(hass), session
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
async_remove_stale_devices(hass, entry)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: AladdinConnectConfigEntry
) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_entry(
hass: HomeAssistant, config_entry: AladdinConnectConfigEntry
) -> bool:
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Migrate old config."""
if config_entry.version < 2:
if config_entry.version < CONFIG_FLOW_VERSION:
config_entry.async_start_reauth(hass)
new_data = {**config_entry.data}
hass.config_entries.async_update_entry(
config_entry,
version=2,
minor_version=1,
data=new_data,
version=CONFIG_FLOW_VERSION,
minor_version=CONFIG_FLOW_MINOR_VERSION,
)
return True
def async_remove_stale_devices(
hass: HomeAssistant, config_entry: AladdinConnectConfigEntry
) -> None:
"""Remove stale devices from device registry."""
device_registry = dr.async_get(hass)
device_entries = dr.async_entries_for_config_entry(
device_registry, config_entry.entry_id
)
all_device_ids = {door.unique_id for door in config_entry.runtime_data.doors}
for device_entry in device_entries:
device_id: str | None = None
for identifier in device_entry.identifiers:
if identifier[0] == DOMAIN:
device_id = identifier[1]
break
if device_id is None or device_id not in all_device_ids:
# If device_id is None an invalid device entry was found for this config entry.
# If the device_id is not in existing device ids it's a stale device entry.
# Remove config entry from this device entry in either case.
device_registry.async_update_device(
device_entry.id, remove_config_entry_id=config_entry.entry_id
)
@@ -1,11 +1,9 @@
"""API for Aladdin Connect Genie bound to Home Assistant OAuth."""
from typing import cast
from aiohttp import ClientSession
from genie_partner_sdk.auth import Auth
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
from homeassistant.helpers import config_entry_oauth2_flow
API_URL = "https://twdvzuefzh.execute-api.us-east-2.amazonaws.com/v1"
API_KEY = "k6QaiQmcTm2zfaNns5L1Z8duBtJmhDOW8JawlCC3"
@@ -17,7 +15,7 @@ class AsyncConfigEntryAuth(Auth): # type: ignore[misc]
def __init__(
self,
websession: ClientSession,
oauth_session: OAuth2Session,
oauth_session: config_entry_oauth2_flow.OAuth2Session,
) -> None:
"""Initialize Aladdin Connect Genie auth."""
super().__init__(
@@ -27,6 +25,7 @@ class AsyncConfigEntryAuth(Auth): # type: ignore[misc]
async def async_get_access_token(self) -> str:
"""Return a valid access token."""
await self._oauth_session.async_ensure_token_valid()
if not self._oauth_session.valid_token:
await self._oauth_session.async_ensure_token_valid()
return cast(str, self._oauth_session.token["access_token"])
return str(self._oauth_session.token["access_token"])
@@ -4,21 +4,22 @@ from collections.abc import Mapping
import logging
from typing import Any
import jwt
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
from homeassistant.helpers import config_entry_oauth2_flow
from .const import DOMAIN
from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION, DOMAIN
class AladdinConnectOAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
class OAuth2FlowHandler(
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
):
"""Config flow to handle Aladdin Connect Genie OAuth2 authentication."""
DOMAIN = DOMAIN
VERSION = 2
MINOR_VERSION = 1
VERSION = CONFIG_FLOW_VERSION
MINOR_VERSION = CONFIG_FLOW_MINOR_VERSION
reauth_entry: ConfigEntry | None = None
@@ -36,33 +37,20 @@ class AladdinConnectOAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required."""
if user_input is None:
return self.async_show_form(step_id="reauth_confirm")
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({}),
)
return await self.async_step_user()
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
"""Create an oauth config entry or update existing entry for reauth."""
token_payload = jwt.decode(
data[CONF_TOKEN][CONF_ACCESS_TOKEN], options={"verify_signature": False}
)
if not self.reauth_entry:
await self.async_set_unique_id(token_payload["sub"])
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=token_payload["username"],
data=data,
)
if self.reauth_entry.unique_id == token_payload["username"]:
if self.reauth_entry:
return self.async_update_reload_and_abort(
self.reauth_entry,
data=data,
unique_id=token_payload["sub"],
)
if self.reauth_entry.unique_id == token_payload["sub"]:
return self.async_update_reload_and_abort(self.reauth_entry, data=data)
return self.async_abort(reason="wrong_account")
return await super().async_oauth_create_entry(data)
@property
def logger(self) -> logging.Logger:
@@ -1,6 +1,14 @@
"""Constants for the Aladdin Connect Genie integration."""
DOMAIN = "aladdin_connect"
from typing import Final
OAUTH2_AUTHORIZE = "https://app.aladdinconnect.net/login.html"
from homeassistant.components.cover import CoverEntityFeature
DOMAIN = "aladdin_connect"
CONFIG_FLOW_VERSION = 2
CONFIG_FLOW_MINOR_VERSION = 1
OAUTH2_AUTHORIZE = "https://app.aladdinconnect.com/login.html"
OAUTH2_TOKEN = "https://twdvzuefzh.execute-api.us-east-2.amazonaws.com/v1/oauth2/token"
SUPPORTED_FEATURES: Final = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
@@ -1,38 +0,0 @@
"""Define an object to coordinate fetching Aladdin Connect data."""
from datetime import timedelta
import logging
from genie_partner_sdk.client import AladdinConnectClient
from genie_partner_sdk.model import GarageDoor
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
class AladdinConnectCoordinator(DataUpdateCoordinator[None]):
"""Aladdin Connect Data Update Coordinator."""
def __init__(self, hass: HomeAssistant, acc: AladdinConnectClient) -> None:
"""Initialize."""
super().__init__(
hass,
logger=_LOGGER,
name=DOMAIN,
update_interval=timedelta(seconds=15),
)
self.acc = acc
self.doors: list[GarageDoor] = []
async def async_setup(self) -> None:
"""Fetch initial data."""
self.doors = await self.acc.get_doors()
async def _async_update_data(self) -> None:
"""Fetch data from API endpoint."""
for door in self.doors:
await self.acc.update_door(door.device_id, door.door_number)
@@ -1,64 +1,115 @@
"""Cover Entity for Genie Garage Door."""
from datetime import timedelta
from typing import Any
from genie_partner_sdk.model import GarageDoor
from genie_partner_sdk.client import AladdinConnectClient
from homeassistant.components.cover import (
CoverDeviceClass,
CoverEntity,
CoverEntityFeature,
)
from homeassistant.components.cover import CoverDeviceClass, CoverEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AladdinConnectConfigEntry, AladdinConnectCoordinator
from .entity import AladdinConnectEntity
from . import api
from .const import DOMAIN, SUPPORTED_FEATURES
from .model import GarageDoor
SCAN_INTERVAL = timedelta(seconds=15)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AladdinConnectConfigEntry,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Aladdin Connect platform."""
coordinator = config_entry.runtime_data
session: api.AsyncConfigEntryAuth = config_entry.runtime_data
acc = AladdinConnectClient(session)
doors = await acc.get_doors()
if doors is None:
raise PlatformNotReady("Error from Aladdin Connect getting doors")
device_registry = dr.async_get(hass)
doors_to_add = []
for door in doors:
existing = device_registry.async_get(door.unique_id)
if existing is None:
doors_to_add.append(door)
async_add_entities(AladdinDevice(coordinator, door) for door in coordinator.doors)
async_add_entities(
(AladdinDevice(acc, door, config_entry) for door in doors_to_add),
)
remove_stale_devices(hass, config_entry, doors)
class AladdinDevice(AladdinConnectEntity, CoverEntity):
def remove_stale_devices(
hass: HomeAssistant, config_entry: ConfigEntry, devices: list[GarageDoor]
) -> None:
"""Remove stale devices from device registry."""
device_registry = dr.async_get(hass)
device_entries = dr.async_entries_for_config_entry(
device_registry, config_entry.entry_id
)
all_device_ids = {door.unique_id for door in devices}
for device_entry in device_entries:
device_id: str | None = None
for identifier in device_entry.identifiers:
if identifier[0] == DOMAIN:
device_id = identifier[1]
break
if device_id is None or device_id not in all_device_ids:
# If device_id is None an invalid device entry was found for this config entry.
# If the device_id is not in existing device ids it's a stale device entry.
# Remove config entry from this device entry in either case.
device_registry.async_update_device(
device_entry.id, remove_config_entry_id=config_entry.entry_id
)
class AladdinDevice(CoverEntity):
"""Representation of Aladdin Connect cover."""
_attr_device_class = CoverDeviceClass.GARAGE
_attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
_attr_supported_features = SUPPORTED_FEATURES
_attr_has_entity_name = True
_attr_name = None
def __init__(
self, coordinator: AladdinConnectCoordinator, device: GarageDoor
self, acc: AladdinConnectClient, device: GarageDoor, entry: ConfigEntry
) -> None:
"""Initialize the Aladdin Connect cover."""
super().__init__(coordinator, device)
self._acc = acc
self._device_id = device.device_id
self._number = device.door_number
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.unique_id)},
name=device.name,
manufacturer="Overhead Door",
)
self._attr_unique_id = device.unique_id
async def async_open_cover(self, **kwargs: Any) -> None:
"""Issue open command to cover."""
await self.coordinator.acc.open_door(
self._device.device_id, self._device.door_number
)
await self._acc.open_door(self._device_id, self._number)
async def async_close_cover(self, **kwargs: Any) -> None:
"""Issue close command to cover."""
await self.coordinator.acc.close_door(
self._device.device_id, self._device.door_number
)
await self._acc.close_door(self._device_id, self._number)
async def async_update(self) -> None:
"""Update status of cover."""
await self._acc.update_door(self._device_id, self._number)
@property
def is_closed(self) -> bool | None:
"""Update is closed attribute."""
value = self.coordinator.acc.get_door_status(
self._device.device_id, self._device.door_number
)
value = self._acc.get_door_status(self._device_id, self._number)
if value is None:
return None
return bool(value == "closed")
@@ -66,9 +117,7 @@ class AladdinDevice(AladdinConnectEntity, CoverEntity):
@property
def is_closing(self) -> bool | None:
"""Update is closing attribute."""
value = self.coordinator.acc.get_door_status(
self._device.device_id, self._device.door_number
)
value = self._acc.get_door_status(self._device_id, self._number)
if value is None:
return None
return bool(value == "closing")
@@ -76,9 +125,7 @@ class AladdinDevice(AladdinConnectEntity, CoverEntity):
@property
def is_opening(self) -> bool | None:
"""Update is opening attribute."""
value = self.coordinator.acc.get_door_status(
self._device.device_id, self._device.door_number
)
value = self._acc.get_door_status(self._device_id, self._number)
if value is None:
return None
return bool(value == "opening")
@@ -1,27 +0,0 @@
"""Defines a base Aladdin Connect entity."""
from genie_partner_sdk.model import GarageDoor
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import AladdinConnectCoordinator
class AladdinConnectEntity(CoordinatorEntity[AladdinConnectCoordinator]):
"""Defines a base Aladdin Connect entity."""
_attr_has_entity_name = True
def __init__(
self, coordinator: AladdinConnectCoordinator, device: GarageDoor
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._device = device
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.unique_id)},
name=device.name,
manufacturer="Overhead Door",
)
@@ -0,0 +1,30 @@
"""Models for Aladdin connect cover platform."""
from __future__ import annotations
from typing import TypedDict
class GarageDoorData(TypedDict):
"""Aladdin door data."""
device_id: str
door_number: int
name: str
status: str
link_status: str
battery_level: int
class GarageDoor:
"""Aladdin Garage Door Entity."""
def __init__(self, data: GarageDoorData) -> None:
"""Create `GarageDoor` from dictionary of data."""
self.device_id = data["device_id"]
self.door_number = data["door_number"]
self.unique_id = f"{self.device_id}-{self.door_number}"
self.name = data["name"]
self.status = data["status"]
self.link_status = data["link_status"]
self.battery_level = data["battery_level"]
@@ -4,9 +4,9 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import cast
from genie_partner_sdk.client import AladdinConnectClient
from genie_partner_sdk.model import GarageDoor
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -14,19 +14,22 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AladdinConnectConfigEntry, AladdinConnectCoordinator
from .entity import AladdinConnectEntity
from . import api
from .const import DOMAIN
from .model import GarageDoor
@dataclass(frozen=True, kw_only=True)
class AccSensorEntityDescription(SensorEntityDescription):
"""Describes AladdinConnect sensor entity."""
value_fn: Callable[[AladdinConnectClient, str, int], float | None]
value_fn: Callable
SENSORS: tuple[AccSensorEntityDescription, ...] = (
@@ -42,39 +45,52 @@ SENSORS: tuple[AccSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: AladdinConnectConfigEntry,
async_add_entities: AddEntitiesCallback,
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up Aladdin Connect sensor devices."""
coordinator = entry.runtime_data
async_add_entities(
AladdinConnectSensor(coordinator, door, description)
for description in SENSORS
for door in coordinator.doors
)
session: api.AsyncConfigEntryAuth = hass.data[DOMAIN][entry.entry_id]
acc = AladdinConnectClient(session)
entities = []
doors = await acc.get_doors()
for door in doors:
entities.extend(
[AladdinConnectSensor(acc, door, description) for description in SENSORS]
)
async_add_entities(entities)
class AladdinConnectSensor(AladdinConnectEntity, SensorEntity):
class AladdinConnectSensor(SensorEntity):
"""A sensor implementation for Aladdin Connect devices."""
entity_description: AccSensorEntityDescription
_attr_has_entity_name = True
def __init__(
self,
coordinator: AladdinConnectCoordinator,
acc: AladdinConnectClient,
device: GarageDoor,
description: AccSensorEntityDescription,
) -> None:
"""Initialize a sensor for an Aladdin Connect device."""
super().__init__(coordinator, device)
self._device_id = device.device_id
self._number = device.door_number
self._acc = acc
self.entity_description = description
self._attr_unique_id = f"{device.unique_id}-{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.unique_id)},
name=device.name,
manufacturer="Overhead Door",
)
@property
def native_value(self) -> float | None:
"""Return the state of the sensor."""
return self.entity_description.value_fn(
self.coordinator.acc, self._device.device_id, self._device.door_number
return cast(
float,
self.entity_description.value_fn(self._acc, self._device_id, self._number),
)
@@ -17,10 +17,6 @@
"is_armed_night": "{entity_name} is armed night",
"is_armed_vacation": "{entity_name} is armed vacation"
},
"extra_fields": {
"code": "Code",
"for": "[%key:common::device_automation::extra_fields::for%]"
},
"trigger_type": {
"triggered": "{entity_name} triggered",
"disarmed": "{entity_name} disarmed",
@@ -2,11 +2,10 @@
from __future__ import annotations
from collections.abc import Generator
import logging
from typing import Any
from typing_extensions import Generator
from homeassistant.components import (
button,
climate,
@@ -261,7 +260,7 @@ class AlexaCapability:
return result
def serialize_properties(self) -> Generator[dict[str, Any]]:
def serialize_properties(self) -> Generator[dict[str, Any], None, None]:
"""Return properties serialized for an API response."""
for prop in self.properties_supported():
prop_name = prop["name"]
+24 -26
View File
@@ -2,12 +2,10 @@
from __future__ import annotations
from collections.abc import Iterable
from collections.abc import Generator, Iterable
import logging
from typing import TYPE_CHECKING, Any
from typing_extensions import Generator
from homeassistant.components import (
alarm_control_panel,
alert,
@@ -321,7 +319,7 @@ class AlexaEntity:
"""
raise NotImplementedError
def serialize_properties(self) -> Generator[dict[str, Any]]:
def serialize_properties(self) -> Generator[dict[str, Any], None, None]:
"""Yield each supported property in API format."""
for interface in self.interfaces():
if not interface.properties_proactively_reported():
@@ -407,7 +405,7 @@ class GenericCapabilities(AlexaEntity):
return [DisplayCategory.OTHER]
def interfaces(self) -> Generator[AlexaCapability]:
def interfaces(self) -> Generator[AlexaCapability, None, None]:
"""Yield the supported interfaces."""
yield AlexaPowerController(self.entity)
yield AlexaEndpointHealth(self.hass, self.entity)
@@ -430,7 +428,7 @@ class SwitchCapabilities(AlexaEntity):
return [DisplayCategory.SWITCH]
def interfaces(self) -> Generator[AlexaCapability]:
def interfaces(self) -> Generator[AlexaCapability, None, None]:
"""Yield the supported interfaces."""
yield AlexaPowerController(self.entity)
yield AlexaContactSensor(self.hass, self.entity)
@@ -447,7 +445,7 @@ class ButtonCapabilities(AlexaEntity):
"""Return the display categories for this entity."""
return [DisplayCategory.ACTIVITY_TRIGGER]
def interfaces(self) -> Generator[AlexaCapability]:
def interfaces(self) -> Generator[AlexaCapability, None, None]:
"""Yield the supported interfaces."""
yield AlexaSceneController(self.entity, supports_deactivation=False)
yield AlexaEventDetectionSensor(self.hass, self.entity)
@@ -466,7 +464,7 @@ class ClimateCapabilities(AlexaEntity):
return [DisplayCategory.WATER_HEATER]
return [DisplayCategory.THERMOSTAT]
def interfaces(self) -> Generator[AlexaCapability]:
def interfaces(self) -> Generator[AlexaCapability, None, None]:
"""Yield the supported interfaces."""
# If we support two modes, one being off, we allow turning on too.
supported_features = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
@@ -534,7 +532,7 @@ class CoverCapabilities(AlexaEntity):
return [DisplayCategory.OTHER]
def interfaces(self) -> Generator[AlexaCapability]:
def interfaces(self) -> Generator[AlexaCapability, None, None]:
"""Yield the supported interfaces."""
device_class = self.entity.attributes.get(ATTR_DEVICE_CLASS)
if device_class not in (
@@ -572,7 +570,7 @@ class EventCapabilities(AlexaEntity):
return [DisplayCategory.DOORBELL]
return None
def interfaces(self) -> Generator[AlexaCapability]:
def interfaces(self) -> Generator[AlexaCapability, None, None]:
"""Yield the supported interfaces."""
if self.default_display_categories() is not None:
yield AlexaDoorbellEventSource(self.entity)
@@ -588,7 +586,7 @@ class LightCapabilities(AlexaEntity):
"""Return the display categories for this entity."""
return [DisplayCategory.LIGHT]
def interfaces(self) -> Generator[AlexaCapability]:
def interfaces(self) -> Generator[AlexaCapability, None, None]:
"""Yield the supported interfaces."""
yield AlexaPowerController(self.entity)
@@ -612,7 +610,7 @@ class FanCapabilities(AlexaEntity):
"""Return the display categories for this entity."""
return [DisplayCategory.FAN]
def interfaces(self) -> Generator[AlexaCapability]:
def interfaces(self) -> Generator[AlexaCapability, None, None]:
"""Yield the supported interfaces."""
yield AlexaPowerController(self.entity)
force_range_controller = True
@@ -655,7 +653,7 @@ class HumidifierCapabilities(AlexaEntity):
"""Return the display categories for this entity."""
return [DisplayCategory.OTHER]
def interfaces(self) -> Generator[AlexaCapability]:
def interfaces(self) -> Generator[AlexaCapability, None, None]:
"""Yield the supported interfaces."""
yield AlexaPowerController(self.entity)
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
@@ -679,7 +677,7 @@ class LockCapabilities(AlexaEntity):
"""Return the display categories for this entity."""
return [DisplayCategory.SMARTLOCK]
def interfaces(self) -> Generator[AlexaCapability]:
def interfaces(self) -> Generator[AlexaCapability, None, None]:
"""Yield the supported interfaces."""
yield AlexaLockController(self.entity)
yield AlexaEndpointHealth(self.hass, self.entity)
@@ -698,7 +696,7 @@ class MediaPlayerCapabilities(AlexaEntity):
return [DisplayCategory.TV]
def interfaces(self) -> Generator[AlexaCapability]:
def interfaces(self) -> Generator[AlexaCapability, None, None]:
"""Yield the supported interfaces."""
yield AlexaPowerController(self.entity)
@@ -768,7 +766,7 @@ class SceneCapabilities(AlexaEntity):
"""Return the display categories for this entity."""
return [DisplayCategory.SCENE_TRIGGER]
def interfaces(self) -> Generator[AlexaCapability]:
def interfaces(self) -> Generator[AlexaCapability, None, None]:
"""Yield the supported interfaces."""
yield AlexaSceneController(self.entity, supports_deactivation=False)
yield Alexa(self.entity)
@@ -782,7 +780,7 @@ class ScriptCapabilities(AlexaEntity):
"""Return the display categories for this entity."""
return [DisplayCategory.ACTIVITY_TRIGGER]
def interfaces(self) -> Generator[AlexaCapability]:
def interfaces(self) -> Generator[AlexaCapability, None, None]:
"""Yield the supported interfaces."""
yield AlexaSceneController(self.entity, supports_deactivation=True)
yield Alexa(self.entity)
@@ -798,7 +796,7 @@ class SensorCapabilities(AlexaEntity):
# sensors are currently ignored.
return [DisplayCategory.TEMPERATURE_SENSOR]
def interfaces(self) -> Generator[AlexaCapability]:
def interfaces(self) -> Generator[AlexaCapability, None, None]:
"""Yield the supported interfaces."""
attrs = self.entity.attributes
if attrs.get(ATTR_UNIT_OF_MEASUREMENT) in {
@@ -829,7 +827,7 @@ class BinarySensorCapabilities(AlexaEntity):
return [DisplayCategory.CAMERA]
return None
def interfaces(self) -> Generator[AlexaCapability]:
def interfaces(self) -> Generator[AlexaCapability, None, None]:
"""Yield the supported interfaces."""
sensor_type = self.get_type()
if sensor_type is self.TYPE_CONTACT:
@@ -885,7 +883,7 @@ class AlarmControlPanelCapabilities(AlexaEntity):
"""Return the display categories for this entity."""
return [DisplayCategory.SECURITY_PANEL]
def interfaces(self) -> Generator[AlexaCapability]:
def interfaces(self) -> Generator[AlexaCapability, None, None]:
"""Yield the supported interfaces."""
if not self.entity.attributes.get("code_arm_required"):
yield AlexaSecurityPanelController(self.hass, self.entity)
@@ -901,7 +899,7 @@ class ImageProcessingCapabilities(AlexaEntity):
"""Return the display categories for this entity."""
return [DisplayCategory.CAMERA]
def interfaces(self) -> Generator[AlexaCapability]:
def interfaces(self) -> Generator[AlexaCapability, None, None]:
"""Yield the supported interfaces."""
yield AlexaEventDetectionSensor(self.hass, self.entity)
yield AlexaEndpointHealth(self.hass, self.entity)
@@ -917,7 +915,7 @@ class InputNumberCapabilities(AlexaEntity):
"""Return the display categories for this entity."""
return [DisplayCategory.OTHER]
def interfaces(self) -> Generator[AlexaCapability]:
def interfaces(self) -> Generator[AlexaCapability, None, None]:
"""Yield the supported interfaces."""
domain = self.entity.domain
yield AlexaRangeController(self.entity, instance=f"{domain}.value")
@@ -933,7 +931,7 @@ class TimerCapabilities(AlexaEntity):
"""Return the display categories for this entity."""
return [DisplayCategory.OTHER]
def interfaces(self) -> Generator[AlexaCapability]:
def interfaces(self) -> Generator[AlexaCapability, None, None]:
"""Yield the supported interfaces."""
yield AlexaTimeHoldController(self.entity, allow_remote_resume=True)
yield AlexaPowerController(self.entity)
@@ -948,7 +946,7 @@ class VacuumCapabilities(AlexaEntity):
"""Return the display categories for this entity."""
return [DisplayCategory.VACUUM_CLEANER]
def interfaces(self) -> Generator[AlexaCapability]:
def interfaces(self) -> Generator[AlexaCapability, None, None]:
"""Yield the supported interfaces."""
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if (
@@ -983,7 +981,7 @@ class ValveCapabilities(AlexaEntity):
"""Return the display categories for this entity."""
return [DisplayCategory.OTHER]
def interfaces(self) -> Generator[AlexaCapability]:
def interfaces(self) -> Generator[AlexaCapability, None, None]:
"""Yield the supported interfaces."""
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if supported & valve.ValveEntityFeature.SET_POSITION:
@@ -1008,7 +1006,7 @@ class CameraCapabilities(AlexaEntity):
"""Return the display categories for this entity."""
return [DisplayCategory.CAMERA]
def interfaces(self) -> Generator[AlexaCapability]:
def interfaces(self) -> Generator[AlexaCapability, None, None]:
"""Yield the supported interfaces."""
if self._check_requirements():
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
@@ -1,7 +1,7 @@
{
"domain": "amazon_polly",
"name": "Amazon Polly",
"codeowners": ["@jschlyter"],
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/amazon_polly",
"iot_class": "cloud_push",
"loggers": ["boto3", "botocore", "s3transfer"],
@@ -182,7 +182,7 @@ class AmbientStation:
# already been done):
if not self._entry_setup_complete:
self._hass.async_create_task(
self._hass.config_entries.async_late_forward_entry_setups(
self._hass.config_entries.async_forward_entry_setups(
self._entry, PLATFORMS
),
eager_start=True,
@@ -61,7 +61,7 @@ ADB_PYTHON_EXCEPTIONS: tuple = (
)
ADB_TCP_EXCEPTIONS: tuple = (ConnectionResetError, RuntimeError)
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE]
PLATFORMS = [Platform.MEDIA_PLAYER]
RELOAD_OPTIONS = [CONF_STATE_DETECTION_RULES]
_INVALID_MACS = {"ff:ff:ff:ff:ff:ff"}
@@ -18,7 +18,6 @@ from homeassistant.const import (
CONF_HOST,
CONF_NAME,
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity import Entity
@@ -88,9 +87,6 @@ def adb_decorator[_ADBDeviceT: AndroidTVEntity, **_P, _R](
await self.aftv.adb_close()
self._attr_available = False
return None
except ServiceValidationError:
# Service validation error is thrown because raised by remote services
raise
except Exception as err: # noqa: BLE001
# An unforeseen exception occurred. Close the ADB connection so that
# it doesn't happen over and over again.
@@ -1,75 +0,0 @@
"""Support for the AndroidTV remote."""
from __future__ import annotations
from collections.abc import Iterable
import logging
from typing import Any
from androidtv.constants import KEYS
from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import CONF_TURN_OFF_COMMAND, CONF_TURN_ON_COMMAND, DOMAIN
from .entity import AndroidTVEntity, adb_decorator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the AndroidTV remote from a config entry."""
async_add_entities([AndroidTVRemote(entry)])
class AndroidTVRemote(AndroidTVEntity, RemoteEntity):
"""Device that sends commands to a AndroidTV."""
_attr_name = None
_attr_should_poll = False
@adb_decorator()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the device."""
options = self._entry_runtime_data.dev_opt
if turn_on_cmd := options.get(CONF_TURN_ON_COMMAND):
await self.aftv.adb_shell(turn_on_cmd)
else:
await self.aftv.turn_on()
@adb_decorator()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the device."""
options = self._entry_runtime_data.dev_opt
if turn_off_cmd := options.get(CONF_TURN_OFF_COMMAND):
await self.aftv.adb_shell(turn_off_cmd)
else:
await self.aftv.turn_off()
@adb_decorator()
async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None:
"""Send a command to a device."""
num_repeats = kwargs[ATTR_NUM_REPEATS]
command_list = []
for cmd in command:
if key := KEYS.get(cmd):
command_list.append(f"input keyevent {key}")
else:
command_list.append(cmd)
for _ in range(num_repeats):
for cmd in command_list:
try:
await self.aftv.adb_shell(cmd)
except UnicodeDecodeError as ex:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="failed_send",
translation_placeholders={"cmd": cmd},
) from ex
@@ -103,10 +103,5 @@
"name": "Learn sendevent",
"description": "Translates a key press on a remote into ADB 'sendevent' commands. You must press one button on the remote within 8 seconds of calling this service."
}
},
"exceptions": {
"failed_send": {
"message": "Failed to send command {cmd}"
}
}
}
@@ -87,9 +87,7 @@ SENSORS: dict[str, SensorEntityDescription] = {
"cumonbatt": SensorEntityDescription(
key="cumonbatt",
translation_key="total_time_on_battery",
native_unit_of_measurement=UnitOfTime.SECONDS,
state_class=SensorStateClass.TOTAL_INCREASING,
device_class=SensorDeviceClass.DURATION,
),
"date": SensorEntityDescription(
key="date",
@@ -342,16 +340,12 @@ SENSORS: dict[str, SensorEntityDescription] = {
"timeleft": SensorEntityDescription(
key="timeleft",
translation_key="time_left",
native_unit_of_measurement=UnitOfTime.MINUTES,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.DURATION,
),
"tonbatt": SensorEntityDescription(
key="tonbatt",
translation_key="time_on_battery",
native_unit_of_measurement=UnitOfTime.SECONDS,
state_class=SensorStateClass.TOTAL_INCREASING,
device_class=SensorDeviceClass.DURATION,
),
"upsmode": SensorEntityDescription(
key="upsmode",
+3 -17
View File
@@ -2,8 +2,6 @@
from __future__ import annotations
from dataclasses import dataclass
from APsystemsEZ1 import APsystemsEZ1M
from homeassistant.config_entries import ConfigEntry
@@ -14,27 +12,15 @@ from .coordinator import ApSystemsDataCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
@dataclass
class ApSystemsData:
"""Store runtime data."""
coordinator: ApSystemsDataCoordinator
device_id: str
type ApsystemsConfigEntry = ConfigEntry[ApSystemsDataCoordinator]
type ApSystemsConfigEntry = ConfigEntry[ApSystemsData]
async def async_setup_entry(hass: HomeAssistant, entry: ApSystemsConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ApsystemsConfigEntry) -> bool:
"""Set up this integration using UI."""
api = APsystemsEZ1M(ip_address=entry.data[CONF_IP_ADDRESS], timeout=8)
coordinator = ApSystemsDataCoordinator(hass, api)
await coordinator.async_config_entry_first_refresh()
assert entry.unique_id
entry.runtime_data = ApSystemsData(
coordinator=coordinator, device_id=entry.unique_id
)
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -1,27 +0,0 @@
"""APsystems base entity."""
from __future__ import annotations
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from . import ApSystemsData
from .const import DOMAIN
class ApSystemsEntity(Entity):
"""Defines a base APsystems entity."""
_attr_has_entity_name = True
def __init__(
self,
data: ApSystemsData,
) -> None:
"""Initialize the APsystems entity."""
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, data.device_id)},
serial_number=data.device_id,
manufacturer="APsystems",
model="EZ1-M",
)
+18 -13
View File
@@ -14,15 +14,16 @@ from homeassistant.components.sensor import (
SensorStateClass,
StateType,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfEnergy, UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import DiscoveryInfoType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import ApSystemsConfigEntry, ApSystemsData
from .const import DOMAIN
from .coordinator import ApSystemsDataCoordinator
from .entity import ApSystemsEntity
@dataclass(frozen=True, kw_only=True)
@@ -110,24 +111,22 @@ SENSORS: tuple[ApsystemsLocalApiSensorDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ApSystemsConfigEntry,
config_entry: ConfigEntry,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the sensor platform."""
config = config_entry.runtime_data
device_id = config_entry.unique_id
assert device_id
add_entities(
ApSystemsSensorWithDescription(
data=config,
entity_description=desc,
)
for desc in SENSORS
ApSystemsSensorWithDescription(config, desc, device_id) for desc in SENSORS
)
class ApSystemsSensorWithDescription(
CoordinatorEntity[ApSystemsDataCoordinator], ApSystemsEntity, SensorEntity
CoordinatorEntity[ApSystemsDataCoordinator], SensorEntity
):
"""Base sensor to be used with description."""
@@ -135,14 +134,20 @@ class ApSystemsSensorWithDescription(
def __init__(
self,
data: ApSystemsData,
coordinator: ApSystemsDataCoordinator,
entity_description: ApsystemsLocalApiSensorDescription,
device_id: str,
) -> None:
"""Initialize the sensor."""
super().__init__(data.coordinator)
ApSystemsEntity.__init__(self, data)
super().__init__(coordinator)
self.entity_description = entity_description
self._attr_unique_id = f"{data.device_id}_{entity_description.key}"
self._attr_unique_id = f"{device_id}_{entity_description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device_id)},
serial_number=device_id,
manufacturer="APsystems",
model="EZ1-M",
)
@property
def native_value(self) -> StateType:
@@ -1,37 +0,0 @@
"""The Aquacell integration."""
from __future__ import annotations
from aioaquacell import AquacellApi
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import AquacellCoordinator
PLATFORMS = [Platform.SENSOR]
AquacellConfigEntry = ConfigEntry[AquacellCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: AquacellConfigEntry) -> bool:
"""Set up Aquacell from a config entry."""
session = async_get_clientsession(hass)
aquacell_api = AquacellApi(session)
coordinator = AquacellCoordinator(hass, aquacell_api)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -1,71 +0,0 @@
"""Config flow for Aquacell integration."""
from __future__ import annotations
from datetime import datetime
import logging
from typing import Any
from aioaquacell import ApiException, AquacellApi, AuthenticationFailed
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_REFRESH_TOKEN, CONF_REFRESH_TOKEN_CREATION_TIME, DOMAIN
_LOGGER = logging.getLogger(__name__)
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_EMAIL): str,
vol.Required(CONF_PASSWORD): str,
}
)
class AquaCellConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Aquacell."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
await self.async_set_unique_id(
user_input[CONF_EMAIL].lower(), raise_on_progress=False
)
self._abort_if_unique_id_configured()
session = async_get_clientsession(self.hass)
api = AquacellApi(session)
try:
refresh_token = await api.authenticate(
user_input[CONF_EMAIL], user_input[CONF_PASSWORD]
)
except ApiException:
errors["base"] = "cannot_connect"
except AuthenticationFailed:
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(
title=user_input[CONF_EMAIL],
data={
**user_input,
CONF_REFRESH_TOKEN: refresh_token,
CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(),
},
)
return self.async_show_form(
step_id="user",
data_schema=DATA_SCHEMA,
errors=errors,
)
@@ -1,12 +0,0 @@
"""Constants for the Aquacell integration."""
from datetime import timedelta
DOMAIN = "aquacell"
DATA_AQUACELL = "DATA_AQUACELL"
CONF_REFRESH_TOKEN = "refresh_token"
CONF_REFRESH_TOKEN_CREATION_TIME = "refresh_token_creation_time"
REFRESH_TOKEN_EXPIRY_TIME = timedelta(days=30)
UPDATE_INTERVAL = timedelta(days=1)
@@ -1,90 +0,0 @@
"""Coordinator to update data from Aquacell API."""
import asyncio
from datetime import datetime
import logging
from aioaquacell import (
AquacellApi,
AquacellApiException,
AuthenticationFailed,
Softener,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
CONF_REFRESH_TOKEN,
CONF_REFRESH_TOKEN_CREATION_TIME,
REFRESH_TOKEN_EXPIRY_TIME,
UPDATE_INTERVAL,
)
_LOGGER = logging.getLogger(__name__)
class AquacellCoordinator(DataUpdateCoordinator[dict[str, Softener]]):
"""My aquacell coordinator."""
config_entry: ConfigEntry
def __init__(self, hass: HomeAssistant, aquacell_api: AquacellApi) -> None:
"""Initialize coordinator."""
super().__init__(
hass,
_LOGGER,
name="Aquacell Coordinator",
update_interval=UPDATE_INTERVAL,
)
self.refresh_token = self.config_entry.data[CONF_REFRESH_TOKEN]
self.refresh_token_creation_time = self.config_entry.data[
CONF_REFRESH_TOKEN_CREATION_TIME
]
self.email = self.config_entry.data[CONF_EMAIL]
self.password = self.config_entry.data[CONF_PASSWORD]
self.aquacell_api = aquacell_api
async def _async_update_data(self) -> dict[str, Softener]:
"""Fetch data from API endpoint.
This is the place to pre-process the data to lookup tables
so entities can quickly look up their data.
"""
async with asyncio.timeout(10):
# Check if the refresh token is expired
expiry_time = (
self.refresh_token_creation_time
+ REFRESH_TOKEN_EXPIRY_TIME.total_seconds()
)
try:
if datetime.now().timestamp() >= expiry_time:
await self._reauthenticate()
else:
await self.aquacell_api.authenticate_refresh(self.refresh_token)
_LOGGER.debug("Logged in using: %s", self.refresh_token)
softeners = await self.aquacell_api.get_all_softeners()
except AuthenticationFailed as err:
raise ConfigEntryError from err
except AquacellApiException as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err
return {softener.dsn: softener for softener in softeners}
async def _reauthenticate(self) -> None:
_LOGGER.debug("Attempting to renew refresh token")
refresh_token = await self.aquacell_api.authenticate(self.email, self.password)
self.refresh_token = refresh_token
data = {
**self.config_entry.data,
CONF_REFRESH_TOKEN: self.refresh_token,
CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(),
}
self.hass.config_entries.async_update_entry(self.config_entry, data=data)
@@ -1,41 +0,0 @@
"""Aquacell entity."""
from aioaquacell import Softener
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import AquacellCoordinator
class AquacellEntity(CoordinatorEntity[AquacellCoordinator]):
"""Representation of an aquacell entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: AquacellCoordinator,
softener_key: str,
entity_key: str,
) -> None:
"""Initialize the aquacell entity."""
super().__init__(coordinator)
self.softener_key = softener_key
self._attr_unique_id = f"{softener_key}-{entity_key}"
self._attr_device_info = DeviceInfo(
name=self.softener.name,
hw_version=self.softener.fwVersion,
identifiers={(DOMAIN, str(softener_key))},
manufacturer=self.softener.brand,
model=self.softener.ssn,
serial_number=softener_key,
)
@property
def softener(self) -> Softener:
"""Handle updated data from the coordinator."""
return self.coordinator.data[self.softener_key]
@@ -1,20 +0,0 @@
{
"entity": {
"sensor": {
"salt_left_side_percentage": {
"default": "mdi:basket-fill"
},
"salt_right_side_percentage": {
"default": "mdi:basket-fill"
},
"wi_fi_strength": {
"default": "mdi:wifi",
"state": {
"low": "mdi:wifi-strength-1",
"medium": "mdi:wifi-strength-2",
"high": "mdi:wifi-strength-4"
}
}
}
}
}
@@ -1,12 +0,0 @@
{
"domain": "aquacell",
"name": "Aquacell",
"codeowners": ["@Jordi1990"],
"config_flow": true,
"dependencies": ["http", "network"],
"documentation": "https://www.home-assistant.io/integrations/aquacell",
"integration_type": "device",
"iot_class": "cloud_polling",
"loggers": ["aioaquacell"],
"requirements": ["aioaquacell==0.1.7"]
}
-117
View File
@@ -1,117 +0,0 @@
"""Sensors exposing properties of the softener device."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from aioaquacell import Softener
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import PERCENTAGE, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import AquacellConfigEntry
from .coordinator import AquacellCoordinator
from .entity import AquacellEntity
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class SoftenerSensorEntityDescription(SensorEntityDescription):
"""Describes Softener sensor entity."""
value_fn: Callable[[Softener], StateType]
SENSORS: tuple[SoftenerSensorEntityDescription, ...] = (
SoftenerSensorEntityDescription(
key="salt_left_side_percentage",
translation_key="salt_left_side_percentage",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda softener: softener.salt.leftPercent,
),
SoftenerSensorEntityDescription(
key="salt_right_side_percentage",
translation_key="salt_right_side_percentage",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda softener: softener.salt.rightPercent,
),
SoftenerSensorEntityDescription(
key="salt_left_side_time_remaining",
translation_key="salt_left_side_time_remaining",
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.DAYS,
value_fn=lambda softener: softener.salt.leftDays,
),
SoftenerSensorEntityDescription(
key="salt_right_side_time_remaining",
translation_key="salt_right_side_time_remaining",
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.DAYS,
value_fn=lambda softener: softener.salt.rightDays,
),
SoftenerSensorEntityDescription(
key="battery",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda softener: softener.battery,
),
SoftenerSensorEntityDescription(
key="wi_fi_strength",
translation_key="wi_fi_strength",
value_fn=lambda softener: softener.wifiLevel,
device_class=SensorDeviceClass.ENUM,
options=[
"high",
"medium",
"low",
],
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AquacellConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the sensors."""
softeners = config_entry.runtime_data.data
async_add_entities(
SoftenerSensor(config_entry.runtime_data, sensor, softener_key)
for sensor in SENSORS
for softener_key in softeners
)
class SoftenerSensor(AquacellEntity, SensorEntity):
"""Softener sensor."""
entity_description: SoftenerSensorEntityDescription
def __init__(
self,
coordinator: AquacellCoordinator,
description: SoftenerSensorEntityDescription,
softener_key: str,
) -> None:
"""Pass coordinator to CoordinatorEntity."""
super().__init__(coordinator, softener_key, description.key)
self.entity_description = description
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.softener)
@@ -1,45 +0,0 @@
{
"config": {
"step": {
"user": {
"description": "Fill in your Aquacell mobile app credentials",
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
}
}
},
"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%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"entity": {
"sensor": {
"salt_left_side_percentage": {
"name": "Salt left side percentage"
},
"salt_right_side_percentage": {
"name": "Salt right side percentage"
},
"salt_left_side_time_remaining": {
"name": "Salt left side time remaining"
},
"salt_right_side_time_remaining": {
"name": "Salt right side time remaining"
},
"wi_fi_strength": {
"name": "Wi-Fi strength",
"state": {
"low": "Low",
"medium": "Medium",
"high": "High"
}
}
}
}
}
@@ -5,7 +5,7 @@ from __future__ import annotations
import array
import asyncio
from collections import defaultdict, deque
from collections.abc import AsyncIterable, Callable, Iterable
from collections.abc import AsyncGenerator, AsyncIterable, Callable, Iterable
from dataclasses import asdict, dataclass, field
from enum import StrEnum
import logging
@@ -16,7 +16,6 @@ import time
from typing import TYPE_CHECKING, Any, Final, Literal, cast
import wave
from typing_extensions import AsyncGenerator
import voluptuous as vol
if TYPE_CHECKING:
@@ -923,7 +922,7 @@ class PipelineRun:
stt_vad: VoiceCommandSegmenter | None,
sample_rate: int = 16000,
sample_width: int = 2,
) -> AsyncGenerator[bytes]:
) -> AsyncGenerator[bytes, None]:
"""Yield audio chunks until VAD detects silence or speech-to-text completes."""
chunk_seconds = AUDIO_PROCESSOR_SAMPLES / sample_rate
sent_vad_start = False
@@ -1186,7 +1185,7 @@ class PipelineRun:
audio_stream: AsyncIterable[bytes],
sample_rate: int = 16000,
sample_width: int = 2,
) -> AsyncGenerator[ProcessedAudioChunk]:
) -> AsyncGenerator[ProcessedAudioChunk, None]:
"""Apply volume transformation only (no VAD/audio enhancements) with optional chunking."""
ms_per_sample = sample_rate // 1000
ms_per_chunk = (AUDIO_PROCESSOR_SAMPLES // sample_width) // ms_per_sample
@@ -1221,7 +1220,7 @@ class PipelineRun:
audio_stream: AsyncIterable[bytes],
sample_rate: int = 16000,
sample_width: int = 2,
) -> AsyncGenerator[ProcessedAudioChunk]:
) -> AsyncGenerator[ProcessedAudioChunk, None]:
"""Split audio into 10 ms chunks and apply VAD/noise suppression/auto gain/volume transformation."""
assert self.audio_processor is not None
@@ -1387,7 +1386,7 @@ class PipelineInput:
# Send audio in the buffer first to speech-to-text, then move on to stt_stream.
# This is basically an async itertools.chain.
async def buffer_then_audio_stream() -> (
AsyncGenerator[ProcessedAudioChunk]
AsyncGenerator[ProcessedAudioChunk, None]
):
# Buffered audio
for chunk in stt_audio_buffer:
@@ -1648,7 +1647,9 @@ class PipelineStorageCollectionWebsocket(
try:
await super().ws_delete_item(hass, connection, msg)
except PipelinePreferred as exc:
connection.send_error(msg["id"], websocket_api.ERR_NOT_ALLOWED, str(exc))
connection.send_error(
msg["id"], websocket_api.const.ERR_NOT_ALLOWED, str(exc)
)
@callback
def ws_get_item(
@@ -1662,7 +1663,7 @@ class PipelineStorageCollectionWebsocket(
if item_id not in self.storage_collection.data:
connection.send_error(
msg["id"],
websocket_api.ERR_NOT_FOUND,
websocket_api.const.ERR_NOT_FOUND,
f"Unable to find {self.item_id_key} {item_id}",
)
return
@@ -1693,7 +1694,7 @@ class PipelineStorageCollectionWebsocket(
self.storage_collection.async_set_preferred_item(msg[self.item_id_key])
except ItemNotFound:
connection.send_error(
msg["id"], websocket_api.ERR_NOT_FOUND, "unknown item"
msg["id"], websocket_api.const.ERR_NOT_FOUND, "unknown item"
)
return
connection.send_result(msg["id"])
@@ -5,13 +5,12 @@ import asyncio
# Suppressing disable=deprecated-module is needed for Python 3.11
import audioop # pylint: disable=deprecated-module
import base64
from collections.abc import Callable
from collections.abc import AsyncGenerator, Callable
import contextlib
import logging
import math
from typing import Any, Final
from typing_extensions import AsyncGenerator
import voluptuous as vol
from homeassistant.components import conversation, stt, tts, websocket_api
@@ -166,7 +165,7 @@ async def websocket_run(
elif start_stage == PipelineStage.STT:
wake_word_phrase = msg["input"].get("wake_word_phrase")
async def stt_stream() -> AsyncGenerator[bytes]:
async def stt_stream() -> AsyncGenerator[bytes, None]:
state = None
# Yield until we receive an empty chunk
@@ -353,7 +352,7 @@ def websocket_get_run(
if pipeline_id not in pipeline_data.pipeline_debug:
connection.send_error(
msg["id"],
websocket_api.ERR_NOT_FOUND,
websocket_api.const.ERR_NOT_FOUND,
f"pipeline_id {pipeline_id} not found",
)
return
@@ -363,7 +362,7 @@ def websocket_get_run(
if pipeline_run_id not in pipeline_debug:
connection.send_error(
msg["id"],
websocket_api.ERR_NOT_FOUND,
websocket_api.const.ERR_NOT_FOUND,
f"pipeline_run_id {pipeline_run_id} not found",
)
return
+453 -25
View File
@@ -2,33 +2,60 @@
from __future__ import annotations
from typing import cast
import asyncio
from collections.abc import Callable, Coroutine, Iterable, ValuesView
from datetime import datetime
from itertools import chain
import logging
from typing import Any
from aiohttp import ClientResponseError
from path import Path
from aiohttp import ClientError, ClientResponseError
from yalexs.activity import ActivityTypes
from yalexs.const import DEFAULT_BRAND
from yalexs.doorbell import Doorbell, DoorbellDetail
from yalexs.exceptions import AugustApiAIOHTTPError
from yalexs.manager.exceptions import CannotConnect, InvalidAuth, RequireValidation
from yalexs.manager.gateway import Config as YaleXSConfig
from yalexs.lock import Lock, LockDetail
from yalexs.pubnub_activity import activities_from_pubnub_message
from yalexs.pubnub_async import AugustPubNub, async_create_pubnub
from yalexs_ble import YaleXSBLEDiscovery
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, ConfigEntry
from homeassistant.const import CONF_PASSWORD
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
HomeAssistantError,
)
from homeassistant.helpers import device_registry as dr, discovery_flow
from .const import DOMAIN, PLATFORMS
from .data import AugustData
from .activity import ActivityStream
from .const import CONF_BRAND, DOMAIN, MIN_TIME_BETWEEN_DETAIL_UPDATES, PLATFORMS
from .exceptions import CannotConnect, InvalidAuth, RequireValidation
from .gateway import AugustGateway
from .subscriber import AugustSubscriberMixin
from .util import async_create_august_clientsession
_LOGGER = logging.getLogger(__name__)
API_CACHED_ATTRS = {
"door_state",
"door_state_datetime",
"lock_status",
"lock_status_datetime",
}
YALEXS_BLE_DOMAIN = "yalexs_ble"
type AugustConfigEntry = ConfigEntry[AugustData]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up August from a config entry."""
session = async_create_august_clientsession(hass)
august_gateway = AugustGateway(Path(hass.config.config_dir), session)
august_gateway = AugustGateway(hass, session)
try:
await august_gateway.async_setup(entry.data)
return await async_setup_august(hass, entry, august_gateway)
except (RequireValidation, InvalidAuth) as err:
raise ConfigEntryAuthFailed from err
@@ -40,38 +67,439 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: AugustConfigEntry) -> bool:
"""Unload a config entry."""
entry.runtime_data.async_stop()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_setup_august(
hass: HomeAssistant, entry: AugustConfigEntry, august_gateway: AugustGateway
hass: HomeAssistant, config_entry: AugustConfigEntry, august_gateway: AugustGateway
) -> bool:
"""Set up the August component."""
config = cast(YaleXSConfig, entry.data)
await august_gateway.async_setup(config)
if CONF_PASSWORD in entry.data:
if CONF_PASSWORD in config_entry.data:
# We no longer need to store passwords since we do not
# support YAML anymore
config_data = entry.data.copy()
config_data = config_entry.data.copy()
del config_data[CONF_PASSWORD]
hass.config_entries.async_update_entry(entry, data=config_data)
hass.config_entries.async_update_entry(config_entry, data=config_data)
await august_gateway.async_authenticate()
await august_gateway.async_refresh_access_token_if_needed()
data = entry.runtime_data = AugustData(hass, entry, august_gateway)
entry.async_on_unload(
hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, data.async_stop)
)
entry.async_on_unload(data.async_stop)
data = config_entry.runtime_data = AugustData(hass, config_entry, august_gateway)
await data.async_setup()
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
return True
@callback
def _async_trigger_ble_lock_discovery(
hass: HomeAssistant, locks_with_offline_keys: list[LockDetail]
) -> None:
"""Update keys for the yalexs-ble integration if available."""
for lock_detail in locks_with_offline_keys:
discovery_flow.async_create_flow(
hass,
YALEXS_BLE_DOMAIN,
context={"source": SOURCE_INTEGRATION_DISCOVERY},
data=YaleXSBLEDiscovery(
{
"name": lock_detail.device_name,
"address": lock_detail.mac_address,
"serial": lock_detail.serial_number,
"key": lock_detail.offline_key,
"slot": lock_detail.offline_slot,
}
),
)
class AugustData(AugustSubscriberMixin):
"""August data object."""
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
august_gateway: AugustGateway,
) -> None:
"""Init August data object."""
super().__init__(hass, MIN_TIME_BETWEEN_DETAIL_UPDATES)
self._config_entry = config_entry
self._hass = hass
self._august_gateway = august_gateway
self.activity_stream: ActivityStream = None # type: ignore[assignment]
self._api = august_gateway.api
self._device_detail_by_id: dict[str, LockDetail | DoorbellDetail] = {}
self._doorbells_by_id: dict[str, Doorbell] = {}
self._locks_by_id: dict[str, Lock] = {}
self._house_ids: set[str] = set()
self._pubnub_unsub: CALLBACK_TYPE | None = None
@property
def brand(self) -> str:
"""Brand of the device."""
return self._config_entry.data.get(CONF_BRAND, DEFAULT_BRAND)
async def async_setup(self) -> None:
"""Async setup of august device data and activities."""
token = self._august_gateway.access_token
# This used to be a gather but it was less reliable with august's recent api changes.
user_data = await self._api.async_get_user(token)
locks: list[Lock] = await self._api.async_get_operable_locks(token)
doorbells: list[Doorbell] = await self._api.async_get_doorbells(token)
if not doorbells:
doorbells = []
if not locks:
locks = []
self._doorbells_by_id = {device.device_id: device for device in doorbells}
self._locks_by_id = {device.device_id: device for device in locks}
self._house_ids = {device.house_id for device in chain(locks, doorbells)}
await self._async_refresh_device_detail_by_ids(
[device.device_id for device in chain(locks, doorbells)]
)
# We remove all devices that we are missing
# detail as we cannot determine if they are usable.
# This also allows us to avoid checking for
# detail being None all over the place
self._remove_inoperative_locks()
self._remove_inoperative_doorbells()
pubnub = AugustPubNub()
for device in self._device_detail_by_id.values():
pubnub.register_device(device)
self.activity_stream = ActivityStream(
self._hass, self._api, self._august_gateway, self._house_ids, pubnub
)
await self.activity_stream.async_setup()
pubnub.subscribe(self.async_pubnub_message)
self._pubnub_unsub = async_create_pubnub(
user_data["UserID"],
pubnub,
self.brand,
)
if self._locks_by_id:
# Do not prevent setup as the sync can timeout
# but it is not a fatal error as the lock
# will recover automatically when it comes back online.
self._config_entry.async_create_background_task(
self._hass, self._async_initial_sync(), "august-initial-sync"
)
async def _async_initial_sync(self) -> None:
"""Attempt to request an initial sync."""
# We don't care if this fails because we only want to wake
# locks that are actually online anyways and they will be
# awake when they come back online
for result in await asyncio.gather(
*[
self.async_status_async(
device_id, bool(detail.bridge and detail.bridge.hyper_bridge)
)
for device_id, detail in self._device_detail_by_id.items()
if device_id in self._locks_by_id
],
return_exceptions=True,
):
if isinstance(result, Exception) and not isinstance(
result, (TimeoutError, ClientResponseError, CannotConnect)
):
_LOGGER.warning(
"Unexpected exception during initial sync: %s",
result,
exc_info=result,
)
@callback
def async_pubnub_message(
self, device_id: str, date_time: datetime, message: dict[str, Any]
) -> None:
"""Process a pubnub message."""
device = self.get_device_detail(device_id)
activities = activities_from_pubnub_message(device, date_time, message)
activity_stream = self.activity_stream
if activities and activity_stream.async_process_newer_device_activities(
activities
):
self.async_signal_device_id_update(device.device_id)
activity_stream.async_schedule_house_id_refresh(device.house_id)
@callback
def async_stop(self) -> None:
"""Stop the subscriptions."""
if self._pubnub_unsub:
self._pubnub_unsub()
self.activity_stream.async_stop()
@property
def doorbells(self) -> ValuesView[Doorbell]:
"""Return a list of py-august Doorbell objects."""
return self._doorbells_by_id.values()
@property
def locks(self) -> ValuesView[Lock]:
"""Return a list of py-august Lock objects."""
return self._locks_by_id.values()
def get_device_detail(self, device_id: str) -> DoorbellDetail | LockDetail:
"""Return the py-august LockDetail or DoorbellDetail object for a device."""
return self._device_detail_by_id[device_id]
async def _async_refresh(self, time: datetime) -> None:
await self._async_refresh_device_detail_by_ids(self._subscriptions.keys())
async def _async_refresh_device_detail_by_ids(
self, device_ids_list: Iterable[str]
) -> None:
"""Refresh each device in sequence.
This used to be a gather but it was less reliable with august's
recent api changes.
The august api has been timing out for some devices so
we want the ones that it isn't timing out for to keep working.
"""
for device_id in device_ids_list:
try:
await self._async_refresh_device_detail_by_id(device_id)
except TimeoutError:
_LOGGER.warning(
"Timed out calling august api during refresh of device: %s",
device_id,
)
except (ClientResponseError, CannotConnect) as err:
_LOGGER.warning(
"Error from august api during refresh of device: %s",
device_id,
exc_info=err,
)
async def refresh_camera_by_id(self, device_id: str) -> None:
"""Re-fetch doorbell/camera data from API."""
await self._async_update_device_detail(
self._doorbells_by_id[device_id],
self._api.async_get_doorbell_detail,
)
async def _async_refresh_device_detail_by_id(self, device_id: str) -> None:
if device_id in self._locks_by_id:
if self.activity_stream and self.activity_stream.pubnub.connected:
saved_attrs = _save_live_attrs(self._device_detail_by_id[device_id])
await self._async_update_device_detail(
self._locks_by_id[device_id], self._api.async_get_lock_detail
)
if self.activity_stream and self.activity_stream.pubnub.connected:
_restore_live_attrs(self._device_detail_by_id[device_id], saved_attrs)
# keypads are always attached to locks
if (
device_id in self._device_detail_by_id
and self._device_detail_by_id[device_id].keypad is not None
):
keypad = self._device_detail_by_id[device_id].keypad
self._device_detail_by_id[keypad.device_id] = keypad
elif device_id in self._doorbells_by_id:
await self._async_update_device_detail(
self._doorbells_by_id[device_id],
self._api.async_get_doorbell_detail,
)
_LOGGER.debug(
"async_signal_device_id_update (from detail updates): %s", device_id
)
self.async_signal_device_id_update(device_id)
async def _async_update_device_detail(
self,
device: Doorbell | Lock,
api_call: Callable[
[str, str], Coroutine[Any, Any, DoorbellDetail | LockDetail]
],
) -> None:
device_id = device.device_id
device_name = device.device_name
_LOGGER.debug("Started retrieving detail for %s (%s)", device_name, device_id)
try:
detail = await api_call(self._august_gateway.access_token, device_id)
except ClientError as ex:
_LOGGER.error(
"Request error trying to retrieve %s details for %s. %s",
device_id,
device_name,
ex,
)
_LOGGER.debug("Completed retrieving detail for %s (%s)", device_name, device_id)
# If the key changes after startup we need to trigger a
# discovery to keep it up to date
if isinstance(detail, LockDetail) and detail.offline_key:
_async_trigger_ble_lock_discovery(self._hass, [detail])
self._device_detail_by_id[device_id] = detail
def get_device(self, device_id: str) -> Doorbell | Lock | None:
"""Get a device by id."""
return self._locks_by_id.get(device_id) or self._doorbells_by_id.get(device_id)
def _get_device_name(self, device_id: str) -> str | None:
"""Return doorbell or lock name as August has it stored."""
if device := self.get_device(device_id):
return device.device_name
return None
async def async_lock(self, device_id: str) -> list[ActivityTypes]:
"""Lock the device."""
return await self._async_call_api_op_requires_bridge(
device_id,
self._api.async_lock_return_activities,
self._august_gateway.access_token,
device_id,
)
async def async_status_async(self, device_id: str, hyper_bridge: bool) -> str:
"""Request status of the device but do not wait for a response since it will come via pubnub."""
return await self._async_call_api_op_requires_bridge(
device_id,
self._api.async_status_async,
self._august_gateway.access_token,
device_id,
hyper_bridge,
)
async def async_lock_async(self, device_id: str, hyper_bridge: bool) -> str:
"""Lock the device but do not wait for a response since it will come via pubnub."""
return await self._async_call_api_op_requires_bridge(
device_id,
self._api.async_lock_async,
self._august_gateway.access_token,
device_id,
hyper_bridge,
)
async def async_unlatch(self, device_id: str) -> list[ActivityTypes]:
"""Open/unlatch the device."""
return await self._async_call_api_op_requires_bridge(
device_id,
self._api.async_unlatch_return_activities,
self._august_gateway.access_token,
device_id,
)
async def async_unlatch_async(self, device_id: str, hyper_bridge: bool) -> str:
"""Open/unlatch the device but do not wait for a response since it will come via pubnub."""
return await self._async_call_api_op_requires_bridge(
device_id,
self._api.async_unlatch_async,
self._august_gateway.access_token,
device_id,
hyper_bridge,
)
async def async_unlock(self, device_id: str) -> list[ActivityTypes]:
"""Unlock the device."""
return await self._async_call_api_op_requires_bridge(
device_id,
self._api.async_unlock_return_activities,
self._august_gateway.access_token,
device_id,
)
async def async_unlock_async(self, device_id: str, hyper_bridge: bool) -> str:
"""Unlock the device but do not wait for a response since it will come via pubnub."""
return await self._async_call_api_op_requires_bridge(
device_id,
self._api.async_unlock_async,
self._august_gateway.access_token,
device_id,
hyper_bridge,
)
async def _async_call_api_op_requires_bridge[**_P, _R](
self,
device_id: str,
func: Callable[_P, Coroutine[Any, Any, _R]],
*args: _P.args,
**kwargs: _P.kwargs,
) -> _R:
"""Call an API that requires the bridge to be online and will change the device state."""
try:
ret = await func(*args, **kwargs)
except AugustApiAIOHTTPError as err:
device_name = self._get_device_name(device_id)
if device_name is None:
device_name = f"DeviceID: {device_id}"
raise HomeAssistantError(f"{device_name}: {err}") from err
return ret
def _remove_inoperative_doorbells(self) -> None:
for doorbell in list(self.doorbells):
device_id = doorbell.device_id
if self._device_detail_by_id.get(device_id):
continue
_LOGGER.info(
(
"The doorbell %s could not be setup because the system could not"
" fetch details about the doorbell"
),
doorbell.device_name,
)
del self._doorbells_by_id[device_id]
def _remove_inoperative_locks(self) -> None:
# Remove non-operative locks as there must
# be a bridge (August Connect) for them to
# be usable
for lock in list(self.locks):
device_id = lock.device_id
lock_detail = self._device_detail_by_id.get(device_id)
if lock_detail is None:
_LOGGER.info(
(
"The lock %s could not be setup because the system could not"
" fetch details about the lock"
),
lock.device_name,
)
elif lock_detail.bridge is None:
_LOGGER.info(
(
"The lock %s could not be setup because it does not have a"
" bridge (Connect)"
),
lock.device_name,
)
del self._device_detail_by_id[device_id]
# Bridge may come back online later so we still add the device since we will
# have a pubnub subscription to tell use when it recovers
else:
continue
del self._locks_by_id[device_id]
def _save_live_attrs(lock_detail: DoorbellDetail | LockDetail) -> dict[str, Any]:
"""Store the attributes that the lock detail api may have an invalid cache for.
Since we are connected to pubnub we may have more current data
then the api so we want to restore the most current data after
updating battery state etc.
"""
return {attr: getattr(lock_detail, attr) for attr in API_CACHED_ATTRS}
def _restore_live_attrs(
lock_detail: DoorbellDetail | LockDetail, attrs: dict[str, Any]
) -> None:
"""Restore the non-cache attributes after a cached update."""
for attr, value in attrs.items():
setattr(lock_detail, attr, value)
async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: AugustConfigEntry, device_entry: dr.DeviceEntry
) -> bool:
+231
View File
@@ -0,0 +1,231 @@
"""Consume the august activity stream."""
from __future__ import annotations
from datetime import datetime
from functools import partial
import logging
from time import monotonic
from aiohttp import ClientError
from yalexs.activity import Activity, ActivityType
from yalexs.api_async import ApiAsync
from yalexs.pubnub_async import AugustPubNub
from yalexs.util import get_latest_activity
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.event import async_call_later
from homeassistant.util.dt import utcnow
from .const import ACTIVITY_UPDATE_INTERVAL
from .gateway import AugustGateway
from .subscriber import AugustSubscriberMixin
_LOGGER = logging.getLogger(__name__)
ACTIVITY_STREAM_FETCH_LIMIT = 10
ACTIVITY_CATCH_UP_FETCH_LIMIT = 2500
INITIAL_LOCK_RESYNC_TIME = 60
# If there is a storm of activity (ie lock, unlock, door open, door close, etc)
# we want to debounce the updates so we don't hammer the activity api too much.
ACTIVITY_DEBOUNCE_COOLDOWN = 4
@callback
def _async_cancel_future_scheduled_updates(cancels: list[CALLBACK_TYPE]) -> None:
"""Cancel future scheduled updates."""
for cancel in cancels:
cancel()
cancels.clear()
class ActivityStream(AugustSubscriberMixin):
"""August activity stream handler."""
def __init__(
self,
hass: HomeAssistant,
api: ApiAsync,
august_gateway: AugustGateway,
house_ids: set[str],
pubnub: AugustPubNub,
) -> None:
"""Init August activity stream object."""
super().__init__(hass, ACTIVITY_UPDATE_INTERVAL)
self._hass = hass
self._schedule_updates: dict[str, list[CALLBACK_TYPE]] = {}
self._august_gateway = august_gateway
self._api = api
self._house_ids = house_ids
self._latest_activities: dict[str, dict[ActivityType, Activity]] = {}
self._did_first_update = False
self.pubnub = pubnub
self._update_debounce: dict[str, Debouncer] = {}
self._update_debounce_jobs: dict[str, HassJob] = {}
self._start_time: float | None = None
@callback
def _async_update_house_id_later(self, debouncer: Debouncer, _: datetime) -> None:
"""Call a debouncer from async_call_later."""
debouncer.async_schedule_call()
async def async_setup(self) -> None:
"""Token refresh check and catch up the activity stream."""
self._start_time = monotonic()
update_debounce = self._update_debounce
update_debounce_jobs = self._update_debounce_jobs
for house_id in self._house_ids:
debouncer = Debouncer(
self._hass,
_LOGGER,
cooldown=ACTIVITY_DEBOUNCE_COOLDOWN,
immediate=True,
function=partial(self._async_update_house_id, house_id),
background=True,
)
update_debounce[house_id] = debouncer
update_debounce_jobs[house_id] = HassJob(
partial(self._async_update_house_id_later, debouncer),
f"debounced august activity update for {house_id}",
cancel_on_shutdown=True,
)
await self._async_refresh(utcnow())
self._did_first_update = True
@callback
def async_stop(self) -> None:
"""Cleanup any debounces."""
for debouncer in self._update_debounce.values():
debouncer.async_cancel()
for cancels in self._schedule_updates.values():
_async_cancel_future_scheduled_updates(cancels)
def get_latest_device_activity(
self, device_id: str, activity_types: set[ActivityType]
) -> Activity | None:
"""Return latest activity that is one of the activity_types."""
if not (latest_device_activities := self._latest_activities.get(device_id)):
return None
latest_activity: Activity | None = None
for activity_type in activity_types:
if activity := latest_device_activities.get(activity_type):
if (
latest_activity
and activity.activity_start_time
<= latest_activity.activity_start_time
):
continue
latest_activity = activity
return latest_activity
async def _async_refresh(self, time: datetime) -> None:
"""Update the activity stream from August."""
# This is the only place we refresh the api token
await self._august_gateway.async_refresh_access_token_if_needed()
if self.pubnub.connected:
_LOGGER.debug("Skipping update because pubnub is connected")
return
_LOGGER.debug("Start retrieving device activities")
# Await in sequence to avoid hammering the API
for debouncer in self._update_debounce.values():
await debouncer.async_call()
@callback
def async_schedule_house_id_refresh(self, house_id: str) -> None:
"""Update for a house activities now and once in the future."""
if future_updates := self._schedule_updates.setdefault(house_id, []):
_async_cancel_future_scheduled_updates(future_updates)
debouncer = self._update_debounce[house_id]
debouncer.async_schedule_call()
# Schedule two updates past the debounce time
# to ensure we catch the case where the activity
# api does not update right away and we need to poll
# it again. Sometimes the lock operator or a doorbell
# will not show up in the activity stream right away.
# Only do additional polls if we are past
# the initial lock resync time to avoid a storm
# of activity at setup.
if (
not self._start_time
or monotonic() - self._start_time < INITIAL_LOCK_RESYNC_TIME
):
_LOGGER.debug(
"Skipping additional updates due to ongoing initial lock resync time"
)
return
_LOGGER.debug("Scheduling additional updates for house id %s", house_id)
job = self._update_debounce_jobs[house_id]
for step in (1, 2):
future_updates.append(
async_call_later(
self._hass,
(step * ACTIVITY_DEBOUNCE_COOLDOWN) + 0.1,
job,
)
)
async def _async_update_house_id(self, house_id: str) -> None:
"""Update device activities for a house."""
if self._did_first_update:
limit = ACTIVITY_STREAM_FETCH_LIMIT
else:
limit = ACTIVITY_CATCH_UP_FETCH_LIMIT
_LOGGER.debug("Updating device activity for house id %s", house_id)
try:
activities = await self._api.async_get_house_activities(
self._august_gateway.access_token, house_id, limit=limit
)
except ClientError as ex:
_LOGGER.error(
"Request error trying to retrieve activity for house id %s: %s",
house_id,
ex,
)
# Make sure we process the next house if one of them fails
return
_LOGGER.debug(
"Completed retrieving device activities for house id %s", house_id
)
for device_id in self.async_process_newer_device_activities(activities):
_LOGGER.debug(
"async_signal_device_id_update (from activity stream): %s",
device_id,
)
self.async_signal_device_id_update(device_id)
def async_process_newer_device_activities(
self, activities: list[Activity]
) -> set[str]:
"""Process activities if they are newer than the last one."""
updated_device_ids = set()
latest_activities = self._latest_activities
for activity in activities:
device_id = activity.device_id
activity_type = activity.activity_type
device_activities = latest_activities.setdefault(device_id, {})
# Ignore activities that are older than the latest one unless it is a non
# locking or unlocking activity with the exact same start time.
last_activity = device_activities.get(activity_type)
# The activity stream can have duplicate activities. So we need
# to call get_latest_activity to figure out if if the activity
# is actually newer than the last one.
latest_activity = get_latest_activity(activity, last_activity)
if latest_activity != activity:
continue
device_activities[activity_type] = activity
updated_device_ids.add(device_id)
return updated_device_ids
@@ -15,7 +15,6 @@ from yalexs.activity import (
)
from yalexs.doorbell import Doorbell, DoorbellDetail
from yalexs.lock import Lock, LockDetail, LockDoorStatus
from yalexs.manager.const import ACTIVITY_UPDATE_INTERVAL
from yalexs.util import update_lock_detail_from_activity
from homeassistant.components.binary_sensor import (
@@ -29,6 +28,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_call_later
from . import AugustConfigEntry, AugustData
from .const import ACTIVITY_UPDATE_INTERVAL
from .entity import AugustEntityMixin
_LOGGER = logging.getLogger(__name__)
@@ -3,14 +3,12 @@
from collections.abc import Mapping
from dataclasses import dataclass
import logging
from pathlib import Path
from typing import Any
import aiohttp
import voluptuous as vol
from yalexs.authenticator import ValidationResult
from yalexs.const import BRANDS, DEFAULT_BRAND
from yalexs.manager.exceptions import CannotConnect, InvalidAuth, RequireValidation
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
@@ -25,6 +23,7 @@ from .const import (
LOGIN_METHODS,
VERIFICATION_CODE_KEY,
)
from .exceptions import CannotConnect, InvalidAuth, RequireValidation
from .gateway import AugustGateway
from .util import async_create_august_clientsession
@@ -165,9 +164,7 @@ class AugustConfigFlow(ConfigFlow, domain=DOMAIN):
if self._august_gateway is not None:
return self._august_gateway
self._aiohttp_session = async_create_august_clientsession(self.hass)
self._august_gateway = AugustGateway(
Path(self.hass.config.config_dir), self._aiohttp_session
)
self._august_gateway = AugustGateway(self.hass, self._aiohttp_session)
return self._august_gateway
@callback
+11
View File
@@ -1,5 +1,7 @@
"""Constants for August devices."""
from datetime import timedelta
from homeassistant.const import Platform
DEFAULT_TIMEOUT = 25
@@ -35,6 +37,15 @@ ATTR_OPERATION_KEYPAD = "keypad"
ATTR_OPERATION_MANUAL = "manual"
ATTR_OPERATION_TAG = "tag"
# Limit battery, online, and hardware updates to hourly
# in order to reduce the number of api requests and
# avoid hitting rate limits
MIN_TIME_BETWEEN_DETAIL_UPDATES = timedelta(hours=24)
# Activity needs to be checked more frequently as the
# doorbell motion and rings are included here
ACTIVITY_UPDATE_INTERVAL = timedelta(seconds=10)
LOGIN_METHODS = ["phone", "email"]
DEFAULT_LOGIN_METHOD = "email"
-65
View File
@@ -1,65 +0,0 @@
"""Support for August devices."""
from __future__ import annotations
from yalexs.const import DEFAULT_BRAND
from yalexs.lock import LockDetail
from yalexs.manager.const import CONF_BRAND
from yalexs.manager.data import YaleXSData
from yalexs_ble import YaleXSBLEDiscovery
from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import discovery_flow
from .gateway import AugustGateway
YALEXS_BLE_DOMAIN = "yalexs_ble"
@callback
def _async_trigger_ble_lock_discovery(
hass: HomeAssistant, locks_with_offline_keys: list[LockDetail]
) -> None:
"""Update keys for the yalexs-ble integration if available."""
for lock_detail in locks_with_offline_keys:
discovery_flow.async_create_flow(
hass,
YALEXS_BLE_DOMAIN,
context={"source": SOURCE_INTEGRATION_DISCOVERY},
data=YaleXSBLEDiscovery(
{
"name": lock_detail.device_name,
"address": lock_detail.mac_address,
"serial": lock_detail.serial_number,
"key": lock_detail.offline_key,
"slot": lock_detail.offline_slot,
}
),
)
class AugustData(YaleXSData):
"""August data object."""
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
august_gateway: AugustGateway,
) -> None:
"""Init August data object."""
self._hass = hass
self._config_entry = config_entry
super().__init__(august_gateway, HomeAssistantError)
@property
def brand(self) -> str:
"""Brand of the device."""
return self._config_entry.data.get(CONF_BRAND, DEFAULT_BRAND)
@callback
def async_offline_key_discovered(self, detail: LockDetail) -> None:
"""Handle offline key discovery."""
_async_trigger_ble_lock_discovery(self._hass, [detail])
@@ -0,0 +1,15 @@
"""Shared exceptions for the august integration."""
from homeassistant import exceptions
class RequireValidation(exceptions.HomeAssistantError):
"""Error to indicate we require validation (2fa)."""
class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect."""
class InvalidAuth(exceptions.HomeAssistantError):
"""Error to indicate there is invalid auth."""
+134 -3
View File
@@ -1,23 +1,56 @@
"""Handle August connection setup and authentication."""
import asyncio
from collections.abc import Mapping
from http import HTTPStatus
import logging
import os
from typing import Any
from aiohttp import ClientError, ClientResponseError, ClientSession
from yalexs.api_async import ApiAsync
from yalexs.authenticator_async import AuthenticationState, AuthenticatorAsync
from yalexs.authenticator_common import Authentication
from yalexs.const import DEFAULT_BRAND
from yalexs.manager.gateway import Gateway
from yalexs.exceptions import AugustApiAIOHTTPError
from homeassistant.const import CONF_USERNAME
from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME
from homeassistant.core import HomeAssistant, callback
from .const import (
CONF_ACCESS_TOKEN_CACHE_FILE,
CONF_BRAND,
CONF_INSTALL_ID,
CONF_LOGIN_METHOD,
DEFAULT_AUGUST_CONFIG_FILE,
DEFAULT_TIMEOUT,
VERIFICATION_CODE_KEY,
)
from .exceptions import CannotConnect, InvalidAuth, RequireValidation
_LOGGER = logging.getLogger(__name__)
class AugustGateway(Gateway):
class AugustGateway:
"""Handle the connection to August."""
api: ApiAsync
authenticator: AuthenticatorAsync
authentication: Authentication
_access_token_cache_file: str
def __init__(self, hass: HomeAssistant, aiohttp_session: ClientSession) -> None:
"""Init the connection."""
self._aiohttp_session = aiohttp_session
self._token_refresh_lock = asyncio.Lock()
self._hass: HomeAssistant = hass
self._config: Mapping[str, Any] | None = None
@property
def access_token(self) -> str:
"""Access token for the api."""
return self.authentication.access_token
def config_entry(self) -> dict[str, Any]:
"""Config entry."""
assert self._config is not None
@@ -28,3 +61,101 @@ class AugustGateway(Gateway):
CONF_INSTALL_ID: self._config.get(CONF_INSTALL_ID),
CONF_ACCESS_TOKEN_CACHE_FILE: self._access_token_cache_file,
}
@callback
def async_configure_access_token_cache_file(
self, username: str, access_token_cache_file: str | None
) -> str:
"""Configure the access token cache file."""
file = access_token_cache_file or f".{username}{DEFAULT_AUGUST_CONFIG_FILE}"
self._access_token_cache_file = file
return self._hass.config.path(file)
async def async_setup(self, conf: Mapping[str, Any]) -> None:
"""Create the api and authenticator objects."""
if conf.get(VERIFICATION_CODE_KEY):
return
access_token_cache_file_path = self.async_configure_access_token_cache_file(
conf[CONF_USERNAME], conf.get(CONF_ACCESS_TOKEN_CACHE_FILE)
)
self._config = conf
self.api = ApiAsync(
self._aiohttp_session,
timeout=self._config.get(CONF_TIMEOUT, DEFAULT_TIMEOUT),
brand=self._config.get(CONF_BRAND, DEFAULT_BRAND),
)
self.authenticator = AuthenticatorAsync(
self.api,
self._config[CONF_LOGIN_METHOD],
self._config[CONF_USERNAME],
self._config.get(CONF_PASSWORD, ""),
install_id=self._config.get(CONF_INSTALL_ID),
access_token_cache_file=access_token_cache_file_path,
)
await self.authenticator.async_setup_authentication()
async def async_authenticate(self) -> Authentication:
"""Authenticate with the details provided to setup."""
try:
self.authentication = await self.authenticator.async_authenticate()
if self.authentication.state == AuthenticationState.AUTHENTICATED:
# Call the locks api to verify we are actually
# authenticated because we can be authenticated
# by have no access
await self.api.async_get_operable_locks(self.access_token)
except AugustApiAIOHTTPError as ex:
if ex.auth_failed:
raise InvalidAuth from ex
raise CannotConnect from ex
except ClientResponseError as ex:
if ex.status == HTTPStatus.UNAUTHORIZED:
raise InvalidAuth from ex
raise CannotConnect from ex
except ClientError as ex:
_LOGGER.error("Unable to connect to August service: %s", str(ex))
raise CannotConnect from ex
if self.authentication.state == AuthenticationState.BAD_PASSWORD:
raise InvalidAuth
if self.authentication.state == AuthenticationState.REQUIRES_VALIDATION:
raise RequireValidation
if self.authentication.state != AuthenticationState.AUTHENTICATED:
_LOGGER.error("Unknown authentication state: %s", self.authentication.state)
raise InvalidAuth
return self.authentication
async def async_reset_authentication(self) -> None:
"""Remove the cache file."""
await self._hass.async_add_executor_job(self._reset_authentication)
def _reset_authentication(self) -> None:
"""Remove the cache file."""
path = self._hass.config.path(self._access_token_cache_file)
if os.path.exists(path):
os.unlink(path)
async def async_refresh_access_token_if_needed(self) -> None:
"""Refresh the august access token if needed."""
if not self.authenticator.should_refresh():
return
async with self._token_refresh_lock:
refreshed_authentication = (
await self.authenticator.async_refresh_access_token(force=False)
)
_LOGGER.info(
(
"Refreshed august access token. The old token expired at %s, and"
" the new token expires at %s"
),
self.authentication.access_token_expires,
refreshed_authentication.access_token_expires,
)
self.authentication = refreshed_authentication
@@ -28,5 +28,5 @@
"documentation": "https://www.home-assistant.io/integrations/august",
"iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"],
"requirements": ["yalexs==6.0.0", "yalexs-ble==2.4.2"]
"requirements": ["yalexs==3.1.0", "yalexs-ble==2.4.2"]
}
@@ -0,0 +1,98 @@
"""Base class for August entity."""
from __future__ import annotations
from abc import abstractmethod
from datetime import datetime, timedelta
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
from homeassistant.helpers.event import async_track_time_interval
class AugustSubscriberMixin:
"""Base implementation for a subscriber."""
def __init__(self, hass: HomeAssistant, update_interval: timedelta) -> None:
"""Initialize an subscriber."""
super().__init__()
self._hass = hass
self._update_interval = update_interval
self._subscriptions: dict[str, list[CALLBACK_TYPE]] = {}
self._unsub_interval: CALLBACK_TYPE | None = None
self._stop_interval: CALLBACK_TYPE | None = None
@callback
def async_subscribe_device_id(
self, device_id: str, update_callback: CALLBACK_TYPE
) -> CALLBACK_TYPE:
"""Add an callback subscriber.
Returns a callable that can be used to unsubscribe.
"""
if not self._subscriptions:
self._async_setup_listeners()
self._subscriptions.setdefault(device_id, []).append(update_callback)
def _unsubscribe() -> None:
self.async_unsubscribe_device_id(device_id, update_callback)
return _unsubscribe
@abstractmethod
async def _async_refresh(self, time: datetime) -> None:
"""Refresh data."""
@callback
def _async_scheduled_refresh(self, now: datetime) -> None:
"""Call the refresh method."""
self._hass.async_create_background_task(
self._async_refresh(now), name=f"{self} schedule refresh", eager_start=True
)
@callback
def _async_cancel_update_interval(self, _: Event | None = None) -> None:
"""Cancel the scheduled update."""
if self._unsub_interval:
self._unsub_interval()
self._unsub_interval = None
@callback
def _async_setup_listeners(self) -> None:
"""Create interval and stop listeners."""
self._async_cancel_update_interval()
self._unsub_interval = async_track_time_interval(
self._hass,
self._async_scheduled_refresh,
self._update_interval,
name="august refresh",
)
if not self._stop_interval:
self._stop_interval = self._hass.bus.async_listen(
EVENT_HOMEASSISTANT_STOP,
self._async_cancel_update_interval,
)
@callback
def async_unsubscribe_device_id(
self, device_id: str, update_callback: CALLBACK_TYPE
) -> None:
"""Remove a callback subscriber."""
self._subscriptions[device_id].remove(update_callback)
if not self._subscriptions[device_id]:
del self._subscriptions[device_id]
if self._subscriptions:
return
self._async_cancel_update_interval()
@callback
def async_signal_device_id_update(self, device_id: str) -> None:
"""Call the callbacks for a device_id."""
if not self._subscriptions.get(device_id):
return
for update_callback in self._subscriptions[device_id]:
update_callback()
+1 -1
View File
@@ -544,7 +544,7 @@ async def websocket_create_long_lived_access_token(
try:
access_token = hass.auth.async_create_access_token(refresh_token)
except InvalidAuthError as exc:
connection.send_error(msg["id"], websocket_api.ERR_UNAUTHORIZED, str(exc))
connection.send_error(msg["id"], websocket_api.const.ERR_UNAUTHORIZED, str(exc))
return
connection.send_result(msg["id"], access_token)
@@ -1208,7 +1208,7 @@ def websocket_config(
if automation is None:
connection.send_error(
msg["id"], websocket_api.ERR_NOT_FOUND, "Entity not found"
msg["id"], websocket_api.const.ERR_NOT_FOUND, "Entity not found"
)
return
+2 -3
View File
@@ -2,11 +2,10 @@
from __future__ import annotations
from collections.abc import Generator
from contextlib import contextmanager
from typing import Any
from typing_extensions import Generator
from homeassistant.components.trace import (
CONF_STORED_TRACES,
ActionTrace,
@@ -56,7 +55,7 @@ def trace_automation(
blueprint_inputs: ConfigType | None,
context: Context,
trace_config: ConfigType,
) -> Generator[AutomationTrace]:
) -> Generator[AutomationTrace, None, None]:
"""Trace action execution of automation with automation_id."""
trace = AutomationTrace(automation_id, config, blueprint_inputs, context)
async_store_trace(hass, trace, trace_config[CONF_STORED_TRACES])
+1 -1
View File
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/aws",
"iot_class": "cloud_push",
"loggers": ["aiobotocore", "botocore"],
"requirements": ["aiobotocore==2.13.0"]
"requirements": ["aiobotocore==2.12.1"]
}
@@ -62,12 +62,13 @@ async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool:
Adds an empty filter to hass data.
Tries to get a filter from yaml, if present set to hass data.
If config is empty after getting the filter, return, otherwise emit
deprecated warning and pass the rest to the config flow.
"""
hass.data.setdefault(DOMAIN, {DATA_FILTER: FILTER_SCHEMA({})})
hass.data.setdefault(DOMAIN, {DATA_FILTER: {}})
if DOMAIN in yaml_config:
hass.data[DOMAIN][DATA_FILTER] = yaml_config[DOMAIN].pop(CONF_FILTER)
hass.data[DOMAIN][DATA_FILTER] = yaml_config[DOMAIN][CONF_FILTER]
return True
@@ -206,6 +207,6 @@ class AzureDataExplorer:
if "\n" in state.state:
return None, dropped + 1
json_event = json.dumps(obj=state, cls=JSONEncoder)
json_event = str(json.dumps(obj=state, cls=JSONEncoder).encode("utf-8"))
return (json_event, dropped)
@@ -23,7 +23,7 @@ from .const import (
CONF_APP_REG_ID,
CONF_APP_REG_SECRET,
CONF_AUTHORITY_ID,
CONF_USE_QUEUED_CLIENT,
CONF_USE_FREE,
)
_LOGGER = logging.getLogger(__name__)
@@ -35,6 +35,7 @@ class AzureDataExplorerClient:
def __init__(self, data: Mapping[str, Any]) -> None:
"""Create the right class."""
self._cluster_ingest_uri = data[CONF_ADX_CLUSTER_INGEST_URI]
self._database = data[CONF_ADX_DATABASE_NAME]
self._table = data[CONF_ADX_TABLE_NAME]
self._ingestion_properties = IngestionProperties(
@@ -44,36 +45,24 @@ class AzureDataExplorerClient:
ingestion_mapping_reference="ha_json_mapping",
)
# Create client for ingesting data
kcsb_ingest = (
KustoConnectionStringBuilder.with_aad_application_key_authentication(
data[CONF_ADX_CLUSTER_INGEST_URI],
data[CONF_APP_REG_ID],
data[CONF_APP_REG_SECRET],
data[CONF_AUTHORITY_ID],
)
# Create cLient for ingesting and querying data
kcsb = KustoConnectionStringBuilder.with_aad_application_key_authentication(
self._cluster_ingest_uri,
data[CONF_APP_REG_ID],
data[CONF_APP_REG_SECRET],
data[CONF_AUTHORITY_ID],
)
# Create client for querying data
kcsb_query = (
KustoConnectionStringBuilder.with_aad_application_key_authentication(
data[CONF_ADX_CLUSTER_INGEST_URI].replace("ingest-", ""),
data[CONF_APP_REG_ID],
data[CONF_APP_REG_SECRET],
data[CONF_AUTHORITY_ID],
)
)
if data[CONF_USE_QUEUED_CLIENT] is True:
if data[CONF_USE_FREE] is True:
# Queded is the only option supported on free tear of ADX
self.write_client = QueuedIngestClient(kcsb_ingest)
self.write_client = QueuedIngestClient(kcsb)
else:
self.write_client = ManagedStreamingIngestClient.from_dm_kcsb(kcsb_ingest)
self.write_client = ManagedStreamingIngestClient.from_dm_kcsb(kcsb)
self.query_client = KustoClient(kcsb_query)
self.query_client = KustoClient(kcsb)
def test_connection(self) -> None:
"""Test connection, will throw Exception if it cannot connect."""
"""Test connection, will throw Exception when it cannot connect."""
query = f"{self._table} | take 1"
@@ -10,7 +10,6 @@ import voluptuous as vol
from homeassistant import config_entries
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.helpers.selector import BooleanSelector
from . import AzureDataExplorerClient
from .const import (
@@ -20,7 +19,7 @@ from .const import (
CONF_APP_REG_ID,
CONF_APP_REG_SECRET,
CONF_AUTHORITY_ID,
CONF_USE_QUEUED_CLIENT,
CONF_USE_FREE,
DEFAULT_OPTIONS,
DOMAIN,
)
@@ -35,7 +34,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
vol.Required(CONF_APP_REG_ID): str,
vol.Required(CONF_APP_REG_SECRET): str,
vol.Required(CONF_AUTHORITY_ID): str,
vol.Required(CONF_USE_QUEUED_CLIENT, default=False): BooleanSelector(),
vol.Optional(CONF_USE_FREE, default=False): bool,
}
)
@@ -17,7 +17,7 @@ CONF_AUTHORITY_ID = "authority_id"
CONF_SEND_INTERVAL = "send_interval"
CONF_MAX_DELAY = "max_delay"
CONF_FILTER = DATA_FILTER = "filter"
CONF_USE_QUEUED_CLIENT = "use_queued_ingestion"
CONF_USE_FREE = "use_queued_ingestion"
DATA_HUB = "hub"
STEP_USER = "user"
@@ -3,19 +3,14 @@
"step": {
"user": {
"title": "Setup your Azure Data Explorer integration",
"description": "Enter connection details",
"description": "Enter connection details.",
"data": {
"cluster_ingest_uri": "Cluster Ingest URI",
"authority_id": "Authority ID",
"client_id": "Client ID",
"client_secret": "Client secret",
"clusteringesturi": "Cluster Ingest URI",
"database": "Database name",
"table": "Table name",
"use_queued_ingestion": "Use queued ingestion"
},
"data_description": {
"cluster_ingest_uri": "Ingest-URI of the cluster",
"use_queued_ingestion": "Must be enabled when using ADX free cluster"
"client_id": "Client ID",
"client_secret": "Client secret",
"authority_id": "Authority ID"
}
}
},
+102 -18
View File
@@ -2,45 +2,94 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import Final
from aioazuredevops.builds import DevOpsBuild
from aioazuredevops.client import DevOpsClient
from aioazuredevops.core import DevOpsProject
import aiohttp
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
UpdateFailed,
)
from .const import CONF_PAT, CONF_PROJECT, DOMAIN
from .coordinator import AzureDevOpsDataUpdateCoordinator
from .const import CONF_ORG, CONF_PAT, CONF_PROJECT, DOMAIN
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.SENSOR]
BUILDS_QUERY: Final = "?queryOrder=queueTimeDescending&maxBuildsPerDefinition=1"
@dataclass(frozen=True)
class AzureDevOpsEntityDescription(EntityDescription):
"""Class describing Azure DevOps entities."""
organization: str = ""
project: DevOpsProject = None
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Azure DevOps from a config entry."""
aiohttp_session = async_get_clientsession(hass)
client = DevOpsClient(session=aiohttp_session)
# Create the data update coordinator
coordinator = AzureDevOpsDataUpdateCoordinator(
hass,
_LOGGER,
entry=entry,
if entry.data.get(CONF_PAT) is not None:
await client.authorize(entry.data[CONF_PAT], entry.data[CONF_ORG])
if not client.authorized:
raise ConfigEntryAuthFailed(
"Could not authorize with Azure DevOps. You will need to update your"
" token"
)
project = await client.get_project(
entry.data[CONF_ORG],
entry.data[CONF_PROJECT],
)
# Store the coordinator in hass data
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = coordinator
async def async_update_data() -> list[DevOpsBuild]:
"""Fetch data from Azure DevOps."""
# If a personal access token is set, authorize the client
if entry.data.get(CONF_PAT) is not None:
await coordinator.authorize(entry.data[CONF_PAT])
try:
builds = await client.get_builds(
entry.data[CONF_ORG],
entry.data[CONF_PROJECT],
BUILDS_QUERY,
)
except aiohttp.ClientError as exception:
raise UpdateFailed from exception
# Set the project for the coordinator
coordinator.project = await coordinator.get_project(entry.data[CONF_PROJECT])
if builds is None:
raise UpdateFailed("No builds found")
return builds
coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
name=f"{DOMAIN}_coordinator",
update_method=async_update_data,
update_interval=timedelta(seconds=300),
)
# Fetch initial data so we have data when entities subscribe
await coordinator.async_config_entry_first_refresh()
# Set up platforms
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = coordinator, project
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -51,5 +100,40 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
del hass.data[DOMAIN][entry.entry_id]
return unload_ok
class AzureDevOpsEntity(CoordinatorEntity[DataUpdateCoordinator[list[DevOpsBuild]]]):
"""Defines a base Azure DevOps entity."""
_attr_has_entity_name = True
entity_description: AzureDevOpsEntityDescription
def __init__(
self,
coordinator: DataUpdateCoordinator[list[DevOpsBuild]],
entity_description: AzureDevOpsEntityDescription,
) -> None:
"""Initialize the Azure DevOps entity."""
super().__init__(coordinator)
self.entity_description = entity_description
self._attr_unique_id: str = (
f"{entity_description.organization}_{entity_description.key}"
)
self._organization: str = entity_description.organization
self._project_name: str = entity_description.project.name
class AzureDevOpsDeviceEntity(AzureDevOpsEntity):
"""Defines a Azure DevOps device entity."""
@property
def device_info(self) -> DeviceInfo:
"""Return device information about this Azure DevOps instance."""
return DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, self._organization, self._project_name)}, # type: ignore[arg-type]
manufacturer=self._organization,
name=self._project_name,
)
@@ -1,116 +0,0 @@
"""Define the Azure DevOps DataUpdateCoordinator."""
from collections.abc import Callable
from datetime import timedelta
import logging
from typing import Final
from aioazuredevops.builds import DevOpsBuild
from aioazuredevops.client import DevOpsClient
from aioazuredevops.core import DevOpsProject
import aiohttp
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONF_ORG, DOMAIN
from .data import AzureDevOpsData
BUILDS_QUERY: Final = "?queryOrder=queueTimeDescending&maxBuildsPerDefinition=1"
def ado_exception_none_handler(func: Callable) -> Callable:
"""Handle exceptions or None to always return a value or raise."""
async def handler(*args, **kwargs):
try:
response = await func(*args, **kwargs)
except aiohttp.ClientError as exception:
raise UpdateFailed from exception
if response is None:
raise UpdateFailed("No data returned from Azure DevOps")
return response
return handler
class AzureDevOpsDataUpdateCoordinator(DataUpdateCoordinator[AzureDevOpsData]):
"""Class to manage and fetch Azure DevOps data."""
client: DevOpsClient
organization: str
project: DevOpsProject
def __init__(
self,
hass: HomeAssistant,
logger: logging.Logger,
*,
entry: ConfigEntry,
) -> None:
"""Initialize global Azure DevOps data updater."""
self.title = entry.title
super().__init__(
hass=hass,
logger=logger,
name=DOMAIN,
update_interval=timedelta(seconds=300),
)
self.client = DevOpsClient(session=async_get_clientsession(hass))
self.organization = entry.data[CONF_ORG]
@ado_exception_none_handler
async def authorize(
self,
personal_access_token: str,
) -> bool:
"""Authorize with Azure DevOps."""
await self.client.authorize(
personal_access_token,
self.organization,
)
if not self.client.authorized:
raise ConfigEntryAuthFailed(
"Could not authorize with Azure DevOps. You will need to update your"
" token"
)
return True
@ado_exception_none_handler
async def get_project(
self,
project: str,
) -> DevOpsProject | None:
"""Get the project."""
return await self.client.get_project(
self.organization,
project,
)
@ado_exception_none_handler
async def _get_builds(self, project_name: str) -> list[DevOpsBuild] | None:
"""Get the builds."""
return await self.client.get_builds(
self.organization,
project_name,
BUILDS_QUERY,
)
async def _async_update_data(self) -> AzureDevOpsData:
"""Fetch data from Azure DevOps."""
# Get the builds from the project
builds = await self._get_builds(self.project.name)
return AzureDevOpsData(
organization=self.organization,
project=self.project,
builds=builds,
)
@@ -1,15 +0,0 @@
"""Data classes for Azure DevOps integration."""
from dataclasses import dataclass
from aioazuredevops.builds import DevOpsBuild
from aioazuredevops.core import DevOpsProject
@dataclass(frozen=True, kw_only=True)
class AzureDevOpsData:
"""Class describing Azure DevOps data."""
organization: str
project: DevOpsProject
builds: list[DevOpsBuild]

Some files were not shown because too many files have changed in this diff Show More