forked from home-assistant/core
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5e26de06f | |||
| 7dab255c15 | |||
| cea7347ed9 | |||
| f4a876c590 | |||
| 117a02972d | |||
| 3fb40deacb | |||
| 38c88c576b | |||
| e95b63bc89 | |||
| ea44b534e6 | |||
| 7646d853f4 | |||
| 248c7c33b2 | |||
| eb887a707c | |||
| e3ddbb2768 | |||
| 008aec5670 | |||
| d93d7159db | |||
| e6e017dab7 | |||
| 486c72db73 | |||
| 4beb184faf | |||
| 4951b60b1d | |||
| 9095941b62 | |||
| e906812fbd | |||
| 522152e7d2 | |||
| 50acc26812 | |||
| 356374cdc3 | |||
| 98d905562e | |||
| 48342837c0 | |||
| 3e0d9516a9 | |||
| c6c36718b9 | |||
| 8ee1d8865c | |||
| 5d5210b47d | |||
| 27cc97bbeb | |||
| 9728103de4 | |||
| ebf9013569 | |||
| b75f3d9681 | |||
| 0d4990799f | |||
| 1e77a59561 | |||
| 7ee2f09fe1 | |||
| 23d9b4b17f | |||
| a580d834da | |||
| 4fb6e59fdc | |||
| ad3823764a | |||
| 024de4f8a6 |
+13
-7
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -10,7 +10,7 @@ on:
|
||||
- "**strings.json"
|
||||
|
||||
env:
|
||||
DEFAULT_PYTHON: "3.12.3"
|
||||
DEFAULT_PYTHON: "3.11"
|
||||
|
||||
jobs:
|
||||
upload:
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"domain": "ruuvi",
|
||||
"name": "Ruuvi",
|
||||
"integrations": ["ruuvi_gateway", "ruuvitag_ble"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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"][
|
||||
|
||||
@@ -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."]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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."""
|
||||
@@ -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()
|
||||
@@ -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,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])
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user