mirror of
https://github.com/home-assistant/core.git
synced 2025-08-04 13:15:18 +02:00
2024.2.0 (#109883)
This commit is contained in:
83
.coveragerc
83
.coveragerc
@@ -28,7 +28,6 @@ omit =
|
||||
homeassistant/components/adguard/sensor.py
|
||||
homeassistant/components/adguard/switch.py
|
||||
homeassistant/components/ads/*
|
||||
homeassistant/components/aemet/weather_update_coordinator.py
|
||||
homeassistant/components/aftership/__init__.py
|
||||
homeassistant/components/aftership/sensor.py
|
||||
homeassistant/components/agent_dvr/alarm_control_panel.py
|
||||
@@ -47,6 +46,9 @@ omit =
|
||||
homeassistant/components/airtouch4/__init__.py
|
||||
homeassistant/components/airtouch4/climate.py
|
||||
homeassistant/components/airtouch4/coordinator.py
|
||||
homeassistant/components/airtouch5/__init__.py
|
||||
homeassistant/components/airtouch5/climate.py
|
||||
homeassistant/components/airtouch5/entity.py
|
||||
homeassistant/components/airvisual/__init__.py
|
||||
homeassistant/components/airvisual/sensor.py
|
||||
homeassistant/components/airvisual_pro/__init__.py
|
||||
@@ -110,6 +112,12 @@ omit =
|
||||
homeassistant/components/baf/sensor.py
|
||||
homeassistant/components/baf/switch.py
|
||||
homeassistant/components/baidu/tts.py
|
||||
homeassistant/components/bang_olufsen/__init__.py
|
||||
homeassistant/components/bang_olufsen/const.py
|
||||
homeassistant/components/bang_olufsen/entity.py
|
||||
homeassistant/components/bang_olufsen/media_player.py
|
||||
homeassistant/components/bang_olufsen/util.py
|
||||
homeassistant/components/bang_olufsen/websocket.py
|
||||
homeassistant/components/bbox/device_tracker.py
|
||||
homeassistant/components/bbox/sensor.py
|
||||
homeassistant/components/beewi_smartclim/sensor.py
|
||||
@@ -142,6 +150,8 @@ omit =
|
||||
homeassistant/components/braviatv/coordinator.py
|
||||
homeassistant/components/braviatv/media_player.py
|
||||
homeassistant/components/braviatv/remote.py
|
||||
homeassistant/components/bring/coordinator.py
|
||||
homeassistant/components/bring/todo.py
|
||||
homeassistant/components/broadlink/climate.py
|
||||
homeassistant/components/broadlink/light.py
|
||||
homeassistant/components/broadlink/remote.py
|
||||
@@ -174,6 +184,7 @@ omit =
|
||||
homeassistant/components/comed_hourly_pricing/sensor.py
|
||||
homeassistant/components/comelit/__init__.py
|
||||
homeassistant/components/comelit/alarm_control_panel.py
|
||||
homeassistant/components/comelit/climate.py
|
||||
homeassistant/components/comelit/const.py
|
||||
homeassistant/components/comelit/cover.py
|
||||
homeassistant/components/comelit/coordinator.py
|
||||
@@ -273,7 +284,12 @@ omit =
|
||||
homeassistant/components/econet/climate.py
|
||||
homeassistant/components/econet/sensor.py
|
||||
homeassistant/components/econet/water_heater.py
|
||||
homeassistant/components/ecovacs/*
|
||||
homeassistant/components/ecovacs/controller.py
|
||||
homeassistant/components/ecovacs/entity.py
|
||||
homeassistant/components/ecovacs/image.py
|
||||
homeassistant/components/ecovacs/number.py
|
||||
homeassistant/components/ecovacs/util.py
|
||||
homeassistant/components/ecovacs/vacuum.py
|
||||
homeassistant/components/ecowitt/__init__.py
|
||||
homeassistant/components/ecowitt/binary_sensor.py
|
||||
homeassistant/components/ecowitt/entity.py
|
||||
@@ -304,6 +320,8 @@ omit =
|
||||
homeassistant/components/elmax/cover.py
|
||||
homeassistant/components/elmax/switch.py
|
||||
homeassistant/components/elv/*
|
||||
homeassistant/components/elvia/__init__.py
|
||||
homeassistant/components/elvia/importer.py
|
||||
homeassistant/components/emby/media_player.py
|
||||
homeassistant/components/emoncms/sensor.py
|
||||
homeassistant/components/emoncms_history/*
|
||||
@@ -332,6 +350,9 @@ omit =
|
||||
homeassistant/components/environment_canada/weather.py
|
||||
homeassistant/components/envisalink/*
|
||||
homeassistant/components/ephember/climate.py
|
||||
homeassistant/components/epion/__init__.py
|
||||
homeassistant/components/epion/coordinator.py
|
||||
homeassistant/components/epion/sensor.py
|
||||
homeassistant/components/epson/__init__.py
|
||||
homeassistant/components/epson/media_player.py
|
||||
homeassistant/components/epsonworkforce/sensor.py
|
||||
@@ -402,8 +423,6 @@ omit =
|
||||
homeassistant/components/fjaraskupan/sensor.py
|
||||
homeassistant/components/fleetgo/device_tracker.py
|
||||
homeassistant/components/flexit/climate.py
|
||||
homeassistant/components/flexit_bacnet/__init__.py
|
||||
homeassistant/components/flexit_bacnet/const.py
|
||||
homeassistant/components/flexit_bacnet/climate.py
|
||||
homeassistant/components/flic/binary_sensor.py
|
||||
homeassistant/components/flick_electric/__init__.py
|
||||
@@ -421,6 +440,7 @@ omit =
|
||||
homeassistant/components/foscam/__init__.py
|
||||
homeassistant/components/foscam/camera.py
|
||||
homeassistant/components/foscam/coordinator.py
|
||||
homeassistant/components/foscam/entity.py
|
||||
homeassistant/components/foursquare/*
|
||||
homeassistant/components/free_mobile/notify.py
|
||||
homeassistant/components/freebox/camera.py
|
||||
@@ -461,9 +481,11 @@ omit =
|
||||
homeassistant/components/google_cloud/tts.py
|
||||
homeassistant/components/google_maps/device_tracker.py
|
||||
homeassistant/components/google_pubsub/__init__.py
|
||||
homeassistant/components/gpsd/__init__.py
|
||||
homeassistant/components/gpsd/sensor.py
|
||||
homeassistant/components/greenwave/light.py
|
||||
homeassistant/components/growatt_server/__init__.py
|
||||
homeassistant/components/growatt_server/const.py
|
||||
homeassistant/components/growatt_server/sensor.py
|
||||
homeassistant/components/growatt_server/sensor_types/*
|
||||
homeassistant/components/gstreamer/media_player.py
|
||||
@@ -471,9 +493,11 @@ omit =
|
||||
homeassistant/components/guardian/__init__.py
|
||||
homeassistant/components/guardian/binary_sensor.py
|
||||
homeassistant/components/guardian/button.py
|
||||
homeassistant/components/guardian/coordinator.py
|
||||
homeassistant/components/guardian/sensor.py
|
||||
homeassistant/components/guardian/switch.py
|
||||
homeassistant/components/guardian/util.py
|
||||
homeassistant/components/guardian/valve.py
|
||||
homeassistant/components/habitica/__init__.py
|
||||
homeassistant/components/habitica/sensor.py
|
||||
homeassistant/components/harman_kardon_avr/media_player.py
|
||||
@@ -495,6 +519,9 @@ omit =
|
||||
homeassistant/components/hive/sensor.py
|
||||
homeassistant/components/hive/switch.py
|
||||
homeassistant/components/hive/water_heater.py
|
||||
homeassistant/components/hko/__init__.py
|
||||
homeassistant/components/hko/weather.py
|
||||
homeassistant/components/hko/coordinator.py
|
||||
homeassistant/components/hlk_sw16/__init__.py
|
||||
homeassistant/components/hlk_sw16/switch.py
|
||||
homeassistant/components/home_connect/__init__.py
|
||||
@@ -504,8 +531,6 @@ omit =
|
||||
homeassistant/components/home_connect/light.py
|
||||
homeassistant/components/home_connect/sensor.py
|
||||
homeassistant/components/home_connect/switch.py
|
||||
homeassistant/components/home_plus_control/api.py
|
||||
homeassistant/components/home_plus_control/switch.py
|
||||
homeassistant/components/homematic/__init__.py
|
||||
homeassistant/components/homematic/binary_sensor.py
|
||||
homeassistant/components/homematic/climate.py
|
||||
@@ -535,6 +560,8 @@ omit =
|
||||
homeassistant/components/hunterdouglas_powerview/shade_data.py
|
||||
homeassistant/components/hunterdouglas_powerview/util.py
|
||||
homeassistant/components/hvv_departures/__init__.py
|
||||
homeassistant/components/huum/__init__.py
|
||||
homeassistant/components/huum/climate.py
|
||||
homeassistant/components/hvv_departures/binary_sensor.py
|
||||
homeassistant/components/hvv_departures/sensor.py
|
||||
homeassistant/components/ialarm/alarm_control_panel.py
|
||||
@@ -661,10 +688,6 @@ omit =
|
||||
homeassistant/components/lg_netcast/media_player.py
|
||||
homeassistant/components/lg_soundbar/__init__.py
|
||||
homeassistant/components/lg_soundbar/media_player.py
|
||||
homeassistant/components/life360/__init__.py
|
||||
homeassistant/components/life360/button.py
|
||||
homeassistant/components/life360/coordinator.py
|
||||
homeassistant/components/life360/device_tracker.py
|
||||
homeassistant/components/lightwave/*
|
||||
homeassistant/components/limitlessled/light.py
|
||||
homeassistant/components/linksys_smart/device_tracker.py
|
||||
@@ -692,10 +715,16 @@ omit =
|
||||
homeassistant/components/loqed/sensor.py
|
||||
homeassistant/components/luci/device_tracker.py
|
||||
homeassistant/components/luftdaten/sensor.py
|
||||
homeassistant/components/lupusec/*
|
||||
homeassistant/components/lupusec/__init__.py
|
||||
homeassistant/components/lupusec/alarm_control_panel.py
|
||||
homeassistant/components/lupusec/binary_sensor.py
|
||||
homeassistant/components/lupusec/entity.py
|
||||
homeassistant/components/lupusec/switch.py
|
||||
homeassistant/components/lutron/__init__.py
|
||||
homeassistant/components/lutron/binary_sensor.py
|
||||
homeassistant/components/lutron/cover.py
|
||||
homeassistant/components/lutron/entity.py
|
||||
homeassistant/components/lutron/fan.py
|
||||
homeassistant/components/lutron/light.py
|
||||
homeassistant/components/lutron/switch.py
|
||||
homeassistant/components/lutron_caseta/__init__.py
|
||||
@@ -758,8 +787,11 @@ omit =
|
||||
homeassistant/components/motion_blinds/entity.py
|
||||
homeassistant/components/motion_blinds/sensor.py
|
||||
homeassistant/components/motionmount/__init__.py
|
||||
homeassistant/components/motionmount/binary_sensor.py
|
||||
homeassistant/components/motionmount/entity.py
|
||||
homeassistant/components/motionmount/number.py
|
||||
homeassistant/components/motionmount/select.py
|
||||
homeassistant/components/motionmount/sensor.py
|
||||
homeassistant/components/mpd/media_player.py
|
||||
homeassistant/components/mqtt_room/sensor.py
|
||||
homeassistant/components/msteams/notify.py
|
||||
@@ -817,6 +849,7 @@ omit =
|
||||
homeassistant/components/nextcloud/coordinator.py
|
||||
homeassistant/components/nextcloud/entity.py
|
||||
homeassistant/components/nextcloud/sensor.py
|
||||
homeassistant/components/nextcloud/update.py
|
||||
homeassistant/components/nfandroidtv/__init__.py
|
||||
homeassistant/components/nfandroidtv/notify.py
|
||||
homeassistant/components/nibe_heatpump/__init__.py
|
||||
@@ -999,6 +1032,11 @@ omit =
|
||||
homeassistant/components/qrcode/image_processing.py
|
||||
homeassistant/components/quantum_gateway/device_tracker.py
|
||||
homeassistant/components/qvr_pro/*
|
||||
homeassistant/components/rabbitair/__init__.py
|
||||
homeassistant/components/rabbitair/const.py
|
||||
homeassistant/components/rabbitair/coordinator.py
|
||||
homeassistant/components/rabbitair/entity.py
|
||||
homeassistant/components/rabbitair/fan.py
|
||||
homeassistant/components/rachio/__init__.py
|
||||
homeassistant/components/rachio/binary_sensor.py
|
||||
homeassistant/components/rachio/device.py
|
||||
@@ -1031,6 +1069,7 @@ omit =
|
||||
homeassistant/components/renson/fan.py
|
||||
homeassistant/components/renson/binary_sensor.py
|
||||
homeassistant/components/renson/number.py
|
||||
homeassistant/components/renson/time.py
|
||||
homeassistant/components/raspyrfm/*
|
||||
homeassistant/components/recollect_waste/sensor.py
|
||||
homeassistant/components/recorder/repack.py
|
||||
@@ -1069,6 +1108,9 @@ omit =
|
||||
homeassistant/components/ripple/sensor.py
|
||||
homeassistant/components/roborock/coordinator.py
|
||||
homeassistant/components/rocketchat/notify.py
|
||||
homeassistant/components/romy/__init__.py
|
||||
homeassistant/components/romy/coordinator.py
|
||||
homeassistant/components/romy/vacuum.py
|
||||
homeassistant/components/roomba/__init__.py
|
||||
homeassistant/components/roomba/binary_sensor.py
|
||||
homeassistant/components/roomba/braava.py
|
||||
@@ -1309,15 +1351,13 @@ omit =
|
||||
homeassistant/components/system_bridge/notify.py
|
||||
homeassistant/components/system_bridge/sensor.py
|
||||
homeassistant/components/system_bridge/update.py
|
||||
homeassistant/components/systemmonitor/__init__.py
|
||||
homeassistant/components/systemmonitor/sensor.py
|
||||
homeassistant/components/systemmonitor/util.py
|
||||
homeassistant/components/tado/__init__.py
|
||||
homeassistant/components/tado/binary_sensor.py
|
||||
homeassistant/components/tado/climate.py
|
||||
homeassistant/components/tado/device_tracker.py
|
||||
homeassistant/components/tado/sensor.py
|
||||
homeassistant/components/tado/water_heater.py
|
||||
homeassistant/components/tami4/button.py
|
||||
homeassistant/components/tank_utility/sensor.py
|
||||
homeassistant/components/tankerkoenig/__init__.py
|
||||
homeassistant/components/tankerkoenig/binary_sensor.py
|
||||
@@ -1386,6 +1426,11 @@ omit =
|
||||
homeassistant/components/tplink_omada/controller.py
|
||||
homeassistant/components/tplink_omada/update.py
|
||||
homeassistant/components/traccar/device_tracker.py
|
||||
homeassistant/components/traccar_server/__init__.py
|
||||
homeassistant/components/traccar_server/coordinator.py
|
||||
homeassistant/components/traccar_server/device_tracker.py
|
||||
homeassistant/components/traccar_server/entity.py
|
||||
homeassistant/components/traccar_server/helpers.py
|
||||
homeassistant/components/tractive/__init__.py
|
||||
homeassistant/components/tractive/binary_sensor.py
|
||||
homeassistant/components/tractive/device_tracker.py
|
||||
@@ -1485,7 +1530,6 @@ omit =
|
||||
homeassistant/components/vesync/switch.py
|
||||
homeassistant/components/viaggiatreno/sensor.py
|
||||
homeassistant/components/vicare/__init__.py
|
||||
homeassistant/components/vicare/binary_sensor.py
|
||||
homeassistant/components/vicare/button.py
|
||||
homeassistant/components/vicare/climate.py
|
||||
homeassistant/components/vicare/entity.py
|
||||
@@ -1605,7 +1649,9 @@ omit =
|
||||
homeassistant/components/yolink/entity.py
|
||||
homeassistant/components/yolink/light.py
|
||||
homeassistant/components/yolink/lock.py
|
||||
homeassistant/components/yolink/number.py
|
||||
homeassistant/components/yolink/sensor.py
|
||||
homeassistant/components/yolink/services.py
|
||||
homeassistant/components/yolink/siren.py
|
||||
homeassistant/components/yolink/switch.py
|
||||
homeassistant/components/youless/__init__.py
|
||||
@@ -1644,6 +1690,13 @@ omit =
|
||||
homeassistant/components/zwave_me/switch.py
|
||||
homeassistant/components/electrasmart/climate.py
|
||||
homeassistant/components/electrasmart/__init__.py
|
||||
homeassistant/components/myuplink/__init__.py
|
||||
homeassistant/components/myuplink/api.py
|
||||
homeassistant/components/myuplink/application_credentials.py
|
||||
homeassistant/components/myuplink/coordinator.py
|
||||
homeassistant/components/myuplink/entity.py
|
||||
homeassistant/components/myuplink/sensor.py
|
||||
|
||||
|
||||
[report]
|
||||
# Regexes for lines to exclude from consideration
|
||||
|
@@ -5,7 +5,8 @@
|
||||
"postCreateCommand": "script/setup",
|
||||
"postStartCommand": "script/bootstrap",
|
||||
"containerEnv": { "DEVCONTAINER": "1" },
|
||||
"appPort": ["8123:8123"],
|
||||
// Port 5683 udp is used by Shelly integration
|
||||
"appPort": ["8123:8123", "5683:5683/udp"],
|
||||
"runArgs": ["-e", "GIT_EDITOR=code --wait"],
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
|
BIN
.github/assets/screenshot-integrations.png
vendored
Normal file
BIN
.github/assets/screenshot-integrations.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 65 KiB |
BIN
.github/assets/screenshot-states.png
vendored
Normal file
BIN
.github/assets/screenshot-states.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 115 KiB |
20
.github/workflows/builder.yml
vendored
20
.github/workflows/builder.yml
vendored
@@ -10,7 +10,8 @@ on:
|
||||
|
||||
env:
|
||||
BUILD_TYPE: core
|
||||
DEFAULT_PYTHON: "3.11"
|
||||
DEFAULT_PYTHON: "3.12"
|
||||
PIP_TIMEOUT: 60
|
||||
|
||||
jobs:
|
||||
init:
|
||||
@@ -102,7 +103,7 @@ jobs:
|
||||
|
||||
- name: Download nightly wheels of frontend
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: dawidd6/action-download-artifact@v2
|
||||
uses: dawidd6/action-download-artifact@v3.0.0
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
repo: home-assistant/frontend
|
||||
@@ -113,7 +114,7 @@ jobs:
|
||||
|
||||
- name: Download nightly wheels of intents
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: dawidd6/action-download-artifact@v2
|
||||
uses: dawidd6/action-download-artifact@v3.0.0
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
repo: home-assistant/intents-package
|
||||
@@ -179,6 +180,15 @@ jobs:
|
||||
sed -i "s|pyezviz|# pyezviz|g" requirements_all.txt
|
||||
sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt
|
||||
|
||||
- name: Adjustments for 64-bit
|
||||
if: matrix.arch == 'amd64' || matrix.arch == 'aarch64'
|
||||
run: |
|
||||
# Some speedups are only available on 64-bit, and since
|
||||
# we build 32bit images on 64bit hosts, we only enable
|
||||
# the speed ups on 64bit since the wheels for 32bit
|
||||
# are not available.
|
||||
sed -i "s|aiohttp-zlib-ng|aiohttp-zlib-ng\[isal\]|g" requirements_all.txt
|
||||
|
||||
- name: Download Translations
|
||||
run: python3 -m script.translations download
|
||||
env:
|
||||
@@ -197,7 +207,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build base image
|
||||
uses: home-assistant/builder@2023.12.0
|
||||
uses: home-assistant/builder@2024.01.0
|
||||
with:
|
||||
args: |
|
||||
$BUILD_ARGS \
|
||||
@@ -274,7 +284,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build base image
|
||||
uses: home-assistant/builder@2023.12.0
|
||||
uses: home-assistant/builder@2024.01.0
|
||||
with:
|
||||
args: |
|
||||
$BUILD_ARGS \
|
||||
|
56
.github/workflows/ci.yaml
vendored
56
.github/workflows/ci.yaml
vendored
@@ -35,8 +35,8 @@ on:
|
||||
env:
|
||||
CACHE_VERSION: 5
|
||||
PIP_CACHE_VERSION: 4
|
||||
MYPY_CACHE_VERSION: 6
|
||||
HA_SHORT_VERSION: "2024.1"
|
||||
MYPY_CACHE_VERSION: 7
|
||||
HA_SHORT_VERSION: "2024.2"
|
||||
DEFAULT_PYTHON: "3.11"
|
||||
ALL_PYTHON_VERSIONS: "['3.11', '3.12']"
|
||||
# 10.3 is the oldest supported version
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
echo "key=pre-commit-${{ env.CACHE_VERSION }}-${{
|
||||
hashFiles('.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT
|
||||
- name: Filter for core changes
|
||||
uses: dorny/paths-filter@v2.11.1
|
||||
uses: dorny/paths-filter@v3.0.0
|
||||
id: core
|
||||
with:
|
||||
filters: .core_files.yaml
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
echo "Result:"
|
||||
cat .integration_paths.yaml
|
||||
- name: Filter for integration changes
|
||||
uses: dorny/paths-filter@v2.11.1
|
||||
uses: dorny/paths-filter@v3.0.0
|
||||
id: integrations
|
||||
with:
|
||||
filters: .integration_paths.yaml
|
||||
@@ -231,7 +231,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore base Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v3.3.2
|
||||
uses: actions/cache@v4.0.0
|
||||
with:
|
||||
path: venv
|
||||
key: >-
|
||||
@@ -246,7 +246,7 @@ jobs:
|
||||
pip install "$(cat requirements_test.txt | grep pre-commit)"
|
||||
- name: Restore pre-commit environment from cache
|
||||
id: cache-precommit
|
||||
uses: actions/cache@v3.3.2
|
||||
uses: actions/cache@v4.0.0
|
||||
with:
|
||||
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||
lookup-only: true
|
||||
@@ -276,7 +276,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore base Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@v3.3.2
|
||||
uses: actions/cache/restore@v4.0.0
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -285,7 +285,7 @@ jobs:
|
||||
needs.info.outputs.pre-commit_cache_key }}
|
||||
- name: Restore pre-commit environment from cache
|
||||
id: cache-precommit
|
||||
uses: actions/cache/restore@v3.3.2
|
||||
uses: actions/cache/restore@v4.0.0
|
||||
with:
|
||||
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||
fail-on-cache-miss: true
|
||||
@@ -316,7 +316,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore base Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@v3.3.2
|
||||
uses: actions/cache/restore@v4.0.0
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -325,7 +325,7 @@ jobs:
|
||||
needs.info.outputs.pre-commit_cache_key }}
|
||||
- name: Restore pre-commit environment from cache
|
||||
id: cache-precommit
|
||||
uses: actions/cache/restore@v3.3.2
|
||||
uses: actions/cache/restore@v4.0.0
|
||||
with:
|
||||
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||
fail-on-cache-miss: true
|
||||
@@ -355,7 +355,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore base Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@v3.3.2
|
||||
uses: actions/cache/restore@v4.0.0
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -364,7 +364,7 @@ jobs:
|
||||
needs.info.outputs.pre-commit_cache_key }}
|
||||
- name: Restore pre-commit environment from cache
|
||||
id: cache-precommit
|
||||
uses: actions/cache/restore@v3.3.2
|
||||
uses: actions/cache/restore@v4.0.0
|
||||
with:
|
||||
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||
fail-on-cache-miss: true
|
||||
@@ -454,7 +454,7 @@ jobs:
|
||||
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
|
||||
- name: Restore base Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v3.3.2
|
||||
uses: actions/cache@v4.0.0
|
||||
with:
|
||||
path: venv
|
||||
lookup-only: true
|
||||
@@ -463,7 +463,7 @@ jobs:
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Restore pip wheel cache
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: actions/cache@v3.3.2
|
||||
uses: actions/cache@v4.0.0
|
||||
with:
|
||||
path: ${{ env.PIP_CACHE }}
|
||||
key: >-
|
||||
@@ -517,7 +517,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@v3.3.2
|
||||
uses: actions/cache/restore@v4.0.0
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -549,7 +549,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore base Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@v3.3.2
|
||||
uses: actions/cache/restore@v4.0.0
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -582,7 +582,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@v3.3.2
|
||||
uses: actions/cache/restore@v4.0.0
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -597,14 +597,14 @@ jobs:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
python --version
|
||||
pylint --ignore-missing-annotations=y homeassistant
|
||||
pylint --ignore-missing-annotations=y --ignore-wrong-coordinator-module=y homeassistant
|
||||
- name: Run pylint (partially)
|
||||
if: needs.info.outputs.test_full_suite == 'false'
|
||||
shell: bash
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
python --version
|
||||
pylint --ignore-missing-annotations=y homeassistant/components/${{ needs.info.outputs.integrations_glob }}
|
||||
pylint --ignore-missing-annotations=y --ignore-wrong-coordinator-module=y homeassistant/components/${{ needs.info.outputs.integrations_glob }}
|
||||
|
||||
mypy:
|
||||
name: Check mypy
|
||||
@@ -633,7 +633,7 @@ jobs:
|
||||
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
|
||||
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@v3.3.2
|
||||
uses: actions/cache/restore@v4.0.0
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -641,7 +641,7 @@ jobs:
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Restore mypy cache
|
||||
uses: actions/cache@v3.3.2
|
||||
uses: actions/cache@v4.0.0
|
||||
with:
|
||||
path: .mypy_cache
|
||||
key: >-
|
||||
@@ -708,7 +708,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@v3.3.2
|
||||
uses: actions/cache/restore@v4.0.0
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -860,7 +860,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@v3.3.2
|
||||
uses: actions/cache/restore@v4.0.0
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -898,6 +898,7 @@ jobs:
|
||||
python --version
|
||||
set -o pipefail
|
||||
mariadb=$(echo "${{ matrix.mariadb-group }}" | sed "s/:/-/g")
|
||||
echo "mariadb=${mariadb}" >> $GITHUB_OUTPUT
|
||||
cov_params=()
|
||||
if [[ "${{ needs.info.outputs.skip_coverage }}" != "true" ]]; then
|
||||
cov_params+=(--cov="homeassistant.components.recorder")
|
||||
@@ -929,7 +930,8 @@ jobs:
|
||||
if: needs.info.outputs.skip_coverage != 'true'
|
||||
uses: actions/upload-artifact@v3.1.2
|
||||
with:
|
||||
name: coverage-${{ matrix.python-version }}-mariadb
|
||||
name: coverage-${{ matrix.python-version }}-mariadb-${{
|
||||
steps.pytest-partial.outputs.mariadb }}
|
||||
path: coverage.xml
|
||||
- name: Check dirty
|
||||
run: |
|
||||
@@ -984,7 +986,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@v3.3.2
|
||||
uses: actions/cache/restore@v4.0.0
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -1022,6 +1024,7 @@ jobs:
|
||||
python --version
|
||||
set -o pipefail
|
||||
postgresql=$(echo "${{ matrix.postgresql-group }}" | sed "s/:/-/g")
|
||||
echo "postgresql=${postgresql}" >> $GITHUB_OUTPUT
|
||||
cov_params=()
|
||||
if [[ "${{ needs.info.outputs.skip_coverage }}" != "true" ]]; then
|
||||
cov_params+=(--cov="homeassistant.components.recorder")
|
||||
@@ -1054,7 +1057,8 @@ jobs:
|
||||
if: needs.info.outputs.skip_coverage != 'true'
|
||||
uses: actions/upload-artifact@v3.1.0
|
||||
with:
|
||||
name: coverage-${{ matrix.python-version }}-postgresql
|
||||
name: coverage-${{ matrix.python-version }}-${{
|
||||
steps.pytest-partial.outputs.postgresql }}
|
||||
path: coverage.xml
|
||||
- name: Check dirty
|
||||
run: |
|
||||
|
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -29,11 +29,11 @@ jobs:
|
||||
uses: actions/checkout@v4.1.1
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3.22.12
|
||||
uses: github/codeql-action/init@v3.23.2
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3.22.12
|
||||
uses: github/codeql-action/analyze@v3.23.2
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
24
.github/workflows/wheels.yml
vendored
24
.github/workflows/wheels.yml
vendored
@@ -99,14 +99,14 @@ jobs:
|
||||
name: requirements_diff
|
||||
|
||||
- name: Build wheels
|
||||
uses: home-assistant/wheels@2023.10.5
|
||||
uses: home-assistant/wheels@2024.01.0
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
arch: ${{ matrix.arch }}
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||
env-file: true
|
||||
apk: "libffi-dev;openssl-dev;yaml-dev"
|
||||
apk: "libffi-dev;openssl-dev;yaml-dev;nasm"
|
||||
skip-binary: aiohttp
|
||||
constraints: "homeassistant/package_constraints.txt"
|
||||
requirements-diff: "requirements_diff.txt"
|
||||
@@ -160,6 +160,12 @@ jobs:
|
||||
sed -i "s|pyezviz|# pyezviz|g" ${requirement_file}
|
||||
sed -i "s|pykrakenapi|# pykrakenapi|g" ${requirement_file}
|
||||
fi
|
||||
|
||||
# Some speedups are only for 64-bit
|
||||
if [ "${{ matrix.arch }}" = "amd64" ] || [ "${{ matrix.arch }}" = "aarch64" ]; then
|
||||
sed -i "s|aiohttp-zlib-ng|aiohttp-zlib-ng\[isal\]|g" ${requirement_file}
|
||||
fi
|
||||
|
||||
done
|
||||
|
||||
- name: Split requirements all
|
||||
@@ -192,7 +198,7 @@ jobs:
|
||||
sed -i "/numpy/d" homeassistant/package_constraints.txt
|
||||
|
||||
- name: Build wheels (old cython)
|
||||
uses: home-assistant/wheels@2023.10.5
|
||||
uses: home-assistant/wheels@2024.01.0
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
@@ -207,42 +213,42 @@ jobs:
|
||||
pip: "'cython<3'"
|
||||
|
||||
- name: Build wheels (part 1)
|
||||
uses: home-assistant/wheels@2023.10.5
|
||||
uses: home-assistant/wheels@2024.01.0
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
arch: ${{ matrix.arch }}
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||
env-file: true
|
||||
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev"
|
||||
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm"
|
||||
skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf
|
||||
constraints: "homeassistant/package_constraints.txt"
|
||||
requirements-diff: "requirements_diff.txt"
|
||||
requirements: "requirements_all.txtaa"
|
||||
|
||||
- name: Build wheels (part 2)
|
||||
uses: home-assistant/wheels@2023.10.5
|
||||
uses: home-assistant/wheels@2024.01.0
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
arch: ${{ matrix.arch }}
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||
env-file: true
|
||||
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev"
|
||||
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm"
|
||||
skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf
|
||||
constraints: "homeassistant/package_constraints.txt"
|
||||
requirements-diff: "requirements_diff.txt"
|
||||
requirements: "requirements_all.txtab"
|
||||
|
||||
- name: Build wheels (part 3)
|
||||
uses: home-assistant/wheels@2023.10.5
|
||||
uses: home-assistant/wheels@2024.01.0
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
arch: ${{ matrix.arch }}
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||
env-file: true
|
||||
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev"
|
||||
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm"
|
||||
skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf
|
||||
constraints: "homeassistant/package_constraints.txt"
|
||||
requirements-diff: "requirements_diff.txt"
|
||||
|
@@ -83,7 +83,7 @@ repos:
|
||||
pass_filenames: false
|
||||
language: script
|
||||
types: [text]
|
||||
files: ^(homeassistant/.+/(manifest|strings)\.json|homeassistant/brands/.*\.json|\.coveragerc|homeassistant/.+/services\.yaml|script/hassfest/(?!metadata|mypy_config).+\.py)$
|
||||
files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/brands/.*\.json|\.coveragerc|homeassistant/.+/services\.yaml|script/hassfest/(?!metadata|mypy_config).+\.py)$
|
||||
- id: hassfest-metadata
|
||||
name: hassfest-metadata
|
||||
entry: script/run-in-env.sh python3 -m script.hassfest -p metadata
|
||||
|
@@ -42,6 +42,7 @@ homeassistant.components
|
||||
homeassistant.components.abode.*
|
||||
homeassistant.components.accuweather.*
|
||||
homeassistant.components.acer_projector.*
|
||||
homeassistant.components.acmeda.*
|
||||
homeassistant.components.actiontec.*
|
||||
homeassistant.components.adax.*
|
||||
homeassistant.components.adguard.*
|
||||
@@ -49,7 +50,10 @@ homeassistant.components.aftership.*
|
||||
homeassistant.components.air_quality.*
|
||||
homeassistant.components.airly.*
|
||||
homeassistant.components.airnow.*
|
||||
homeassistant.components.airq.*
|
||||
homeassistant.components.airthings.*
|
||||
homeassistant.components.airthings_ble.*
|
||||
homeassistant.components.airtouch5.*
|
||||
homeassistant.components.airvisual.*
|
||||
homeassistant.components.airvisual_pro.*
|
||||
homeassistant.components.airzone.*
|
||||
@@ -58,52 +62,81 @@ homeassistant.components.aladdin_connect.*
|
||||
homeassistant.components.alarm_control_panel.*
|
||||
homeassistant.components.alert.*
|
||||
homeassistant.components.alexa.*
|
||||
homeassistant.components.alpha_vantage.*
|
||||
homeassistant.components.amazon_polly.*
|
||||
homeassistant.components.amberelectric.*
|
||||
homeassistant.components.ambiclimate.*
|
||||
homeassistant.components.ambient_station.*
|
||||
homeassistant.components.amcrest.*
|
||||
homeassistant.components.ampio.*
|
||||
homeassistant.components.analytics.*
|
||||
homeassistant.components.analytics_insights.*
|
||||
homeassistant.components.android_ip_webcam.*
|
||||
homeassistant.components.androidtv.*
|
||||
homeassistant.components.androidtv_remote.*
|
||||
homeassistant.components.anel_pwrctrl.*
|
||||
homeassistant.components.anova.*
|
||||
homeassistant.components.anthemav.*
|
||||
homeassistant.components.apache_kafka.*
|
||||
homeassistant.components.apcupsd.*
|
||||
homeassistant.components.api.*
|
||||
homeassistant.components.apprise.*
|
||||
homeassistant.components.aprs.*
|
||||
homeassistant.components.aqualogic.*
|
||||
homeassistant.components.aquostv.*
|
||||
homeassistant.components.aranet.*
|
||||
homeassistant.components.arcam_fmj.*
|
||||
homeassistant.components.arris_tg2492lg.*
|
||||
homeassistant.components.aruba.*
|
||||
homeassistant.components.arwn.*
|
||||
homeassistant.components.aseko_pool_live.*
|
||||
homeassistant.components.assist_pipeline.*
|
||||
homeassistant.components.asterisk_cdr.*
|
||||
homeassistant.components.asterisk_mbox.*
|
||||
homeassistant.components.asuswrt.*
|
||||
homeassistant.components.auth.*
|
||||
homeassistant.components.automation.*
|
||||
homeassistant.components.awair.*
|
||||
homeassistant.components.axis.*
|
||||
homeassistant.components.backup.*
|
||||
homeassistant.components.baf.*
|
||||
homeassistant.components.bang_olufsen.*
|
||||
homeassistant.components.bayesian.*
|
||||
homeassistant.components.binary_sensor.*
|
||||
homeassistant.components.bitcoin.*
|
||||
homeassistant.components.blockchain.*
|
||||
homeassistant.components.blue_current.*
|
||||
homeassistant.components.blueprint.*
|
||||
homeassistant.components.bluetooth.*
|
||||
homeassistant.components.bluetooth_adapters.*
|
||||
homeassistant.components.bluetooth_tracker.*
|
||||
homeassistant.components.bmw_connected_drive.*
|
||||
homeassistant.components.bond.*
|
||||
homeassistant.components.braviatv.*
|
||||
homeassistant.components.brother.*
|
||||
homeassistant.components.browser.*
|
||||
homeassistant.components.bthome.*
|
||||
homeassistant.components.button.*
|
||||
homeassistant.components.calendar.*
|
||||
homeassistant.components.camera.*
|
||||
homeassistant.components.canary.*
|
||||
homeassistant.components.cert_expiry.*
|
||||
homeassistant.components.clickatell.*
|
||||
homeassistant.components.clicksend.*
|
||||
homeassistant.components.climate.*
|
||||
homeassistant.components.cloud.*
|
||||
homeassistant.components.co2signal.*
|
||||
homeassistant.components.command_line.*
|
||||
homeassistant.components.config.*
|
||||
homeassistant.components.configurator.*
|
||||
homeassistant.components.counter.*
|
||||
homeassistant.components.cover.*
|
||||
homeassistant.components.cpuspeed.*
|
||||
homeassistant.components.crownstone.*
|
||||
homeassistant.components.date.*
|
||||
homeassistant.components.datetime.*
|
||||
homeassistant.components.deconz.*
|
||||
homeassistant.components.default_config.*
|
||||
homeassistant.components.demo.*
|
||||
homeassistant.components.derivative.*
|
||||
homeassistant.components.device_automation.*
|
||||
@@ -114,11 +147,18 @@ homeassistant.components.dhcp.*
|
||||
homeassistant.components.diagnostics.*
|
||||
homeassistant.components.discovergy.*
|
||||
homeassistant.components.dlna_dmr.*
|
||||
homeassistant.components.dlna_dms.*
|
||||
homeassistant.components.dnsip.*
|
||||
homeassistant.components.doorbird.*
|
||||
homeassistant.components.dormakaba_dkey.*
|
||||
homeassistant.components.downloader.*
|
||||
homeassistant.components.dsmr.*
|
||||
homeassistant.components.duckdns.*
|
||||
homeassistant.components.dunehd.*
|
||||
homeassistant.components.duotecno.*
|
||||
homeassistant.components.easyenergy.*
|
||||
homeassistant.components.ecovacs.*
|
||||
homeassistant.components.ecowitt.*
|
||||
homeassistant.components.efergy.*
|
||||
homeassistant.components.electrasmart.*
|
||||
homeassistant.components.electric_kiwi.*
|
||||
@@ -126,7 +166,9 @@ homeassistant.components.elgato.*
|
||||
homeassistant.components.elkm1.*
|
||||
homeassistant.components.emulated_hue.*
|
||||
homeassistant.components.energy.*
|
||||
homeassistant.components.energyzero.*
|
||||
homeassistant.components.enigma2.*
|
||||
homeassistant.components.enphase_envoy.*
|
||||
homeassistant.components.esphome.*
|
||||
homeassistant.components.event.*
|
||||
homeassistant.components.evil_genius_labs.*
|
||||
@@ -148,12 +190,15 @@ homeassistant.components.fritzbox_callmonitor.*
|
||||
homeassistant.components.fronius.*
|
||||
homeassistant.components.frontend.*
|
||||
homeassistant.components.fully_kiosk.*
|
||||
homeassistant.components.generic_hygrostat.*
|
||||
homeassistant.components.generic_thermostat.*
|
||||
homeassistant.components.geo_location.*
|
||||
homeassistant.components.geocaching.*
|
||||
homeassistant.components.gios.*
|
||||
homeassistant.components.glances.*
|
||||
homeassistant.components.goalzero.*
|
||||
homeassistant.components.google.*
|
||||
homeassistant.components.google_assistant_sdk.*
|
||||
homeassistant.components.google_sheets.*
|
||||
homeassistant.components.gpsd.*
|
||||
homeassistant.components.greeneye_monitor.*
|
||||
@@ -163,9 +208,9 @@ homeassistant.components.hardkernel.*
|
||||
homeassistant.components.hardware.*
|
||||
homeassistant.components.here_travel_time.*
|
||||
homeassistant.components.history.*
|
||||
homeassistant.components.history_stats.*
|
||||
homeassistant.components.holiday.*
|
||||
homeassistant.components.homeassistant.exposed_entities
|
||||
homeassistant.components.homeassistant.triggers.event
|
||||
homeassistant.components.homeassistant.*
|
||||
homeassistant.components.homeassistant_alerts.*
|
||||
homeassistant.components.homeassistant_green.*
|
||||
homeassistant.components.homeassistant_hardware.*
|
||||
@@ -184,6 +229,7 @@ homeassistant.components.homekit_controller.utils
|
||||
homeassistant.components.homewizard.*
|
||||
homeassistant.components.http.*
|
||||
homeassistant.components.huawei_lte.*
|
||||
homeassistant.components.humidifier.*
|
||||
homeassistant.components.hydrawise.*
|
||||
homeassistant.components.hyperion.*
|
||||
homeassistant.components.ibeacon.*
|
||||
@@ -196,6 +242,9 @@ homeassistant.components.input_button.*
|
||||
homeassistant.components.input_select.*
|
||||
homeassistant.components.input_text.*
|
||||
homeassistant.components.integration.*
|
||||
homeassistant.components.intent.*
|
||||
homeassistant.components.intent_script.*
|
||||
homeassistant.components.ios.*
|
||||
homeassistant.components.ipp.*
|
||||
homeassistant.components.iqvia.*
|
||||
homeassistant.components.islamic_prayer_times.*
|
||||
@@ -208,11 +257,13 @@ homeassistant.components.knx.*
|
||||
homeassistant.components.kraken.*
|
||||
homeassistant.components.lacrosse.*
|
||||
homeassistant.components.lacrosse_view.*
|
||||
homeassistant.components.lamarzocco.*
|
||||
homeassistant.components.lametric.*
|
||||
homeassistant.components.laundrify.*
|
||||
homeassistant.components.lawn_mower.*
|
||||
homeassistant.components.lcn.*
|
||||
homeassistant.components.ld2410_ble.*
|
||||
homeassistant.components.led_ble.*
|
||||
homeassistant.components.lidarr.*
|
||||
homeassistant.components.lifx.*
|
||||
homeassistant.components.light.*
|
||||
@@ -228,15 +279,18 @@ homeassistant.components.london_underground.*
|
||||
homeassistant.components.lookin.*
|
||||
homeassistant.components.luftdaten.*
|
||||
homeassistant.components.mailbox.*
|
||||
homeassistant.components.map.*
|
||||
homeassistant.components.mastodon.*
|
||||
homeassistant.components.matrix.*
|
||||
homeassistant.components.matter.*
|
||||
homeassistant.components.media_extractor.*
|
||||
homeassistant.components.media_player.*
|
||||
homeassistant.components.media_source.*
|
||||
homeassistant.components.met_eireann.*
|
||||
homeassistant.components.metoffice.*
|
||||
homeassistant.components.mikrotik.*
|
||||
homeassistant.components.min_max.*
|
||||
homeassistant.components.minecraft_server.*
|
||||
homeassistant.components.mjpeg.*
|
||||
homeassistant.components.modbus.*
|
||||
homeassistant.components.modem_callerid.*
|
||||
@@ -244,7 +298,9 @@ homeassistant.components.moon.*
|
||||
homeassistant.components.mopeka.*
|
||||
homeassistant.components.motionmount.*
|
||||
homeassistant.components.mqtt.*
|
||||
homeassistant.components.my.*
|
||||
homeassistant.components.mysensors.*
|
||||
homeassistant.components.myuplink.*
|
||||
homeassistant.components.nam.*
|
||||
homeassistant.components.nanoleaf.*
|
||||
homeassistant.components.neato.*
|
||||
@@ -253,20 +309,24 @@ homeassistant.components.netatmo.*
|
||||
homeassistant.components.network.*
|
||||
homeassistant.components.nextdns.*
|
||||
homeassistant.components.nfandroidtv.*
|
||||
homeassistant.components.nightscout.*
|
||||
homeassistant.components.nissan_leaf.*
|
||||
homeassistant.components.no_ip.*
|
||||
homeassistant.components.notify.*
|
||||
homeassistant.components.notion.*
|
||||
homeassistant.components.number.*
|
||||
homeassistant.components.nut.*
|
||||
homeassistant.components.onboarding.*
|
||||
homeassistant.components.oncue.*
|
||||
homeassistant.components.onewire.*
|
||||
homeassistant.components.open_meteo.*
|
||||
homeassistant.components.openexchangerates.*
|
||||
homeassistant.components.opensky.*
|
||||
homeassistant.components.openuv.*
|
||||
homeassistant.components.oralb.*
|
||||
homeassistant.components.otbr.*
|
||||
homeassistant.components.overkiz.*
|
||||
homeassistant.components.p1_monitor.*
|
||||
homeassistant.components.peco.*
|
||||
homeassistant.components.persistent_notification.*
|
||||
homeassistant.components.pi_hole.*
|
||||
@@ -275,6 +335,7 @@ homeassistant.components.plugwise.*
|
||||
homeassistant.components.poolsense.*
|
||||
homeassistant.components.powerwall.*
|
||||
homeassistant.components.private_ble_device.*
|
||||
homeassistant.components.prometheus.*
|
||||
homeassistant.components.proximity.*
|
||||
homeassistant.components.prusalink.*
|
||||
homeassistant.components.pure_energie.*
|
||||
@@ -282,7 +343,9 @@ homeassistant.components.purpleair.*
|
||||
homeassistant.components.pushbullet.*
|
||||
homeassistant.components.pvoutput.*
|
||||
homeassistant.components.qnap_qsw.*
|
||||
homeassistant.components.rabbitair.*
|
||||
homeassistant.components.radarr.*
|
||||
homeassistant.components.rainforest_raven.*
|
||||
homeassistant.components.rainmachine.*
|
||||
homeassistant.components.raspberry_pi.*
|
||||
homeassistant.components.rdw.*
|
||||
@@ -292,11 +355,13 @@ homeassistant.components.remote.*
|
||||
homeassistant.components.renault.*
|
||||
homeassistant.components.repairs.*
|
||||
homeassistant.components.rest.*
|
||||
homeassistant.components.rest_command.*
|
||||
homeassistant.components.rfxtrx.*
|
||||
homeassistant.components.rhasspy.*
|
||||
homeassistant.components.ridwell.*
|
||||
homeassistant.components.rituals_perfume_genie.*
|
||||
homeassistant.components.roku.*
|
||||
homeassistant.components.romy.*
|
||||
homeassistant.components.rpi_power.*
|
||||
homeassistant.components.rss_feed_template.*
|
||||
homeassistant.components.rtsp_to_webrtc.*
|
||||
@@ -306,6 +371,7 @@ homeassistant.components.samsungtv.*
|
||||
homeassistant.components.scene.*
|
||||
homeassistant.components.schedule.*
|
||||
homeassistant.components.scrape.*
|
||||
homeassistant.components.search.*
|
||||
homeassistant.components.select.*
|
||||
homeassistant.components.sensibo.*
|
||||
homeassistant.components.sensirion_ble.*
|
||||
@@ -313,8 +379,10 @@ homeassistant.components.sensor.*
|
||||
homeassistant.components.senz.*
|
||||
homeassistant.components.sfr_box.*
|
||||
homeassistant.components.shelly.*
|
||||
homeassistant.components.shopping_list.*
|
||||
homeassistant.components.simplepush.*
|
||||
homeassistant.components.simplisafe.*
|
||||
homeassistant.components.siren.*
|
||||
homeassistant.components.skybell.*
|
||||
homeassistant.components.slack.*
|
||||
homeassistant.components.sleepiq.*
|
||||
@@ -330,6 +398,7 @@ homeassistant.components.steamist.*
|
||||
homeassistant.components.stookalert.*
|
||||
homeassistant.components.stream.*
|
||||
homeassistant.components.streamlabswater.*
|
||||
homeassistant.components.stt.*
|
||||
homeassistant.components.suez_water.*
|
||||
homeassistant.components.sun.*
|
||||
homeassistant.components.surepetcare.*
|
||||
@@ -338,6 +407,8 @@ homeassistant.components.switchbee.*
|
||||
homeassistant.components.switchbot_cloud.*
|
||||
homeassistant.components.switcher_kis.*
|
||||
homeassistant.components.synology_dsm.*
|
||||
homeassistant.components.system_health.*
|
||||
homeassistant.components.system_log.*
|
||||
homeassistant.components.systemmonitor.*
|
||||
homeassistant.components.tag.*
|
||||
homeassistant.components.tailscale.*
|
||||
@@ -345,14 +416,22 @@ homeassistant.components.tailwind.*
|
||||
homeassistant.components.tami4.*
|
||||
homeassistant.components.tautulli.*
|
||||
homeassistant.components.tcp.*
|
||||
homeassistant.components.technove.*
|
||||
homeassistant.components.tedee.*
|
||||
homeassistant.components.text.*
|
||||
homeassistant.components.threshold.*
|
||||
homeassistant.components.tibber.*
|
||||
homeassistant.components.tile.*
|
||||
homeassistant.components.tilt_ble.*
|
||||
homeassistant.components.time.*
|
||||
homeassistant.components.time_date.*
|
||||
homeassistant.components.timer.*
|
||||
homeassistant.components.tod.*
|
||||
homeassistant.components.todo.*
|
||||
homeassistant.components.tolo.*
|
||||
homeassistant.components.tplink.*
|
||||
homeassistant.components.tplink_omada.*
|
||||
homeassistant.components.trace.*
|
||||
homeassistant.components.tractive.*
|
||||
homeassistant.components.tradfri.*
|
||||
homeassistant.components.trafikverket_camera.*
|
||||
@@ -376,10 +455,13 @@ homeassistant.components.valve.*
|
||||
homeassistant.components.velbus.*
|
||||
homeassistant.components.vlc_telnet.*
|
||||
homeassistant.components.wake_on_lan.*
|
||||
homeassistant.components.wake_word.*
|
||||
homeassistant.components.wallbox.*
|
||||
homeassistant.components.waqi.*
|
||||
homeassistant.components.water_heater.*
|
||||
homeassistant.components.watttime.*
|
||||
homeassistant.components.weather.*
|
||||
homeassistant.components.webhook.*
|
||||
homeassistant.components.webostv.*
|
||||
homeassistant.components.websocket_api.*
|
||||
homeassistant.components.wemo.*
|
||||
@@ -388,8 +470,10 @@ homeassistant.components.withings.*
|
||||
homeassistant.components.wiz.*
|
||||
homeassistant.components.wled.*
|
||||
homeassistant.components.worldclock.*
|
||||
homeassistant.components.xiaomi_ble.*
|
||||
homeassistant.components.yale_smart_alarm.*
|
||||
homeassistant.components.yalexs_ble.*
|
||||
homeassistant.components.youtube.*
|
||||
homeassistant.components.zeroconf.*
|
||||
homeassistant.components.zodiac.*
|
||||
homeassistant.components.zone.*
|
||||
|
14
.vscode/tasks.json
vendored
14
.vscode/tasks.json
vendored
@@ -157,6 +157,20 @@
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Install integration requirements",
|
||||
"detail": "Install all requirements of a given integration.",
|
||||
"type": "shell",
|
||||
"command": "${command:python.interpreterPath} -m script.install_integration_requirements ${input:integrationName}",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
},
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "new"
|
||||
}
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
|
90
CODEOWNERS
90
CODEOWNERS
@@ -45,12 +45,14 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/airnow/ @asymworks
|
||||
/homeassistant/components/airq/ @Sibgatulin @dl2080
|
||||
/tests/components/airq/ @Sibgatulin @dl2080
|
||||
/homeassistant/components/airthings/ @danielhiversen
|
||||
/tests/components/airthings/ @danielhiversen
|
||||
/homeassistant/components/airthings/ @danielhiversen @LaStrada
|
||||
/tests/components/airthings/ @danielhiversen @LaStrada
|
||||
/homeassistant/components/airthings_ble/ @vincegio @LaStrada
|
||||
/tests/components/airthings_ble/ @vincegio @LaStrada
|
||||
/homeassistant/components/airtouch4/ @samsinnamon
|
||||
/tests/components/airtouch4/ @samsinnamon
|
||||
/homeassistant/components/airtouch5/ @danzel
|
||||
/tests/components/airtouch5/ @danzel
|
||||
/homeassistant/components/airvisual/ @bachya
|
||||
/tests/components/airvisual/ @bachya
|
||||
/homeassistant/components/airvisual_pro/ @bachya
|
||||
@@ -76,6 +78,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/amcrest/ @flacjacket
|
||||
/homeassistant/components/analytics/ @home-assistant/core @ludeeus
|
||||
/tests/components/analytics/ @home-assistant/core @ludeeus
|
||||
/homeassistant/components/analytics_insights/ @joostlek
|
||||
/tests/components/analytics_insights/ @joostlek
|
||||
/homeassistant/components/android_ip_webcam/ @engrbm87
|
||||
/tests/components/android_ip_webcam/ @engrbm87
|
||||
/homeassistant/components/androidtv/ @JeffLIrion @ollo69
|
||||
@@ -145,6 +149,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/baf/ @bdraco @jfroy
|
||||
/homeassistant/components/balboa/ @garbled1 @natekspencer
|
||||
/tests/components/balboa/ @garbled1 @natekspencer
|
||||
/homeassistant/components/bang_olufsen/ @mj23000
|
||||
/tests/components/bang_olufsen/ @mj23000
|
||||
/homeassistant/components/bayesian/ @HarvsG
|
||||
/tests/components/bayesian/ @HarvsG
|
||||
/homeassistant/components/beewi_smartclim/ @alemuro
|
||||
@@ -174,6 +180,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/bosch_shc/ @tschamm
|
||||
/homeassistant/components/braviatv/ @bieniu @Drafteed
|
||||
/tests/components/braviatv/ @bieniu @Drafteed
|
||||
/homeassistant/components/bring/ @miaucl @tr4nt0r
|
||||
/tests/components/bring/ @miaucl @tr4nt0r
|
||||
/homeassistant/components/broadlink/ @danielhiversen @felipediel @L-I-Am @eifinger
|
||||
/tests/components/broadlink/ @danielhiversen @felipediel @L-I-Am @eifinger
|
||||
/homeassistant/components/brother/ @bieniu
|
||||
@@ -317,13 +325,12 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/eafm/ @Jc2k
|
||||
/homeassistant/components/easyenergy/ @klaasnicolaas
|
||||
/tests/components/easyenergy/ @klaasnicolaas
|
||||
/homeassistant/components/ecobee/ @marcolivierarsenault
|
||||
/tests/components/ecobee/ @marcolivierarsenault
|
||||
/homeassistant/components/ecoforest/ @pjanuario
|
||||
/tests/components/ecoforest/ @pjanuario
|
||||
/homeassistant/components/econet/ @w1ll1am23
|
||||
/tests/components/econet/ @w1ll1am23
|
||||
/homeassistant/components/ecovacs/ @OverloadUT @mib1185
|
||||
/homeassistant/components/ecovacs/ @OverloadUT @mib1185 @edenhaus
|
||||
/tests/components/ecovacs/ @OverloadUT @mib1185 @edenhaus
|
||||
/homeassistant/components/ecowitt/ @pvizeli
|
||||
/tests/components/ecowitt/ @pvizeli
|
||||
/homeassistant/components/efergy/ @tkdrob
|
||||
@@ -340,6 +347,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/elmax/ @albertogeniola
|
||||
/tests/components/elmax/ @albertogeniola
|
||||
/homeassistant/components/elv/ @majuss
|
||||
/homeassistant/components/elvia/ @ludeeus
|
||||
/tests/components/elvia/ @ludeeus
|
||||
/homeassistant/components/emby/ @mezz64
|
||||
/homeassistant/components/emoncms/ @borpin
|
||||
/homeassistant/components/emonitor/ @bdraco
|
||||
@@ -361,6 +370,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/environment_canada/ @gwww @michaeldavie
|
||||
/tests/components/environment_canada/ @gwww @michaeldavie
|
||||
/homeassistant/components/ephember/ @ttroy50
|
||||
/homeassistant/components/epion/ @lhgravendeel
|
||||
/tests/components/epion/ @lhgravendeel
|
||||
/homeassistant/components/epson/ @pszafer
|
||||
/tests/components/epson/ @pszafer
|
||||
/homeassistant/components/epsonworkforce/ @ThaStealth
|
||||
@@ -494,7 +505,10 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/google_travel_time/ @eifinger
|
||||
/homeassistant/components/govee_ble/ @bdraco @PierreAronnax
|
||||
/tests/components/govee_ble/ @bdraco @PierreAronnax
|
||||
/homeassistant/components/gpsd/ @fabaff
|
||||
/homeassistant/components/govee_light_local/ @Galorhallen
|
||||
/tests/components/govee_light_local/ @Galorhallen
|
||||
/homeassistant/components/gpsd/ @fabaff @jrieger
|
||||
/tests/components/gpsd/ @fabaff @jrieger
|
||||
/homeassistant/components/gree/ @cmroche
|
||||
/tests/components/gree/ @cmroche
|
||||
/homeassistant/components/greeneye_monitor/ @jkeljo
|
||||
@@ -528,14 +542,14 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/history/ @home-assistant/core
|
||||
/homeassistant/components/hive/ @Rendili @KJonline
|
||||
/tests/components/hive/ @Rendili @KJonline
|
||||
/homeassistant/components/hko/ @MisterCommand
|
||||
/tests/components/hko/ @MisterCommand
|
||||
/homeassistant/components/hlk_sw16/ @jameshilliard
|
||||
/tests/components/hlk_sw16/ @jameshilliard
|
||||
/homeassistant/components/holiday/ @jrieger
|
||||
/tests/components/holiday/ @jrieger
|
||||
/homeassistant/components/holiday/ @jrieger @gjohansson-ST
|
||||
/tests/components/holiday/ @jrieger @gjohansson-ST
|
||||
/homeassistant/components/home_connect/ @DavidMStraub
|
||||
/tests/components/home_connect/ @DavidMStraub
|
||||
/homeassistant/components/home_plus_control/ @chemaaa
|
||||
/tests/components/home_plus_control/ @chemaaa
|
||||
/homeassistant/components/homeassistant/ @home-assistant/core
|
||||
/tests/components/homeassistant/ @home-assistant/core
|
||||
/homeassistant/components/homeassistant_alerts/ @home-assistant/core
|
||||
@@ -570,6 +584,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/humidifier/ @home-assistant/core @Shulyaka
|
||||
/homeassistant/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock
|
||||
/tests/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock
|
||||
/homeassistant/components/huum/ @frwickst
|
||||
/tests/components/huum/ @frwickst
|
||||
/homeassistant/components/hvv_departures/ @vigonotion
|
||||
/tests/components/hvv_departures/ @vigonotion
|
||||
/homeassistant/components/hydrawise/ @dknowles2 @ptcryan
|
||||
@@ -653,8 +669,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/juicenet/ @jesserockz
|
||||
/homeassistant/components/justnimbus/ @kvanzuijlen
|
||||
/tests/components/justnimbus/ @kvanzuijlen
|
||||
/homeassistant/components/jvc_projector/ @SteveEasley
|
||||
/tests/components/jvc_projector/ @SteveEasley
|
||||
/homeassistant/components/jvc_projector/ @SteveEasley @msavazzi
|
||||
/tests/components/jvc_projector/ @SteveEasley @msavazzi
|
||||
/homeassistant/components/kaiterra/ @Michsior14
|
||||
/homeassistant/components/kaleidescape/ @SteveEasley
|
||||
/tests/components/kaleidescape/ @SteveEasley
|
||||
@@ -685,6 +701,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/kulersky/ @emlove
|
||||
/homeassistant/components/lacrosse_view/ @IceBotYT
|
||||
/tests/components/lacrosse_view/ @IceBotYT
|
||||
/homeassistant/components/lamarzocco/ @zweckj
|
||||
/tests/components/lamarzocco/ @zweckj
|
||||
/homeassistant/components/lametric/ @robbiet480 @frenck @bachya
|
||||
/tests/components/lametric/ @robbiet480 @frenck @bachya
|
||||
/homeassistant/components/landisgyr_heat_meter/ @vpathuis
|
||||
@@ -701,13 +719,13 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/lcn/ @alengwenus
|
||||
/homeassistant/components/ld2410_ble/ @930913
|
||||
/tests/components/ld2410_ble/ @930913
|
||||
/homeassistant/components/leaone/ @bdraco
|
||||
/tests/components/leaone/ @bdraco
|
||||
/homeassistant/components/led_ble/ @bdraco
|
||||
/tests/components/led_ble/ @bdraco
|
||||
/homeassistant/components/lg_netcast/ @Drafteed
|
||||
/homeassistant/components/lidarr/ @tkdrob
|
||||
/tests/components/lidarr/ @tkdrob
|
||||
/homeassistant/components/life360/ @pnbruckner
|
||||
/tests/components/life360/ @pnbruckner
|
||||
/homeassistant/components/light/ @home-assistant/core
|
||||
/tests/components/light/ @home-assistant/core
|
||||
/homeassistant/components/linear_garage_door/ @IceBotYT
|
||||
@@ -744,8 +762,10 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/luci/ @mzdrale
|
||||
/homeassistant/components/luftdaten/ @fabaff @frenck
|
||||
/tests/components/luftdaten/ @fabaff @frenck
|
||||
/homeassistant/components/lupusec/ @majuss
|
||||
/homeassistant/components/lutron/ @cdheiser
|
||||
/homeassistant/components/lupusec/ @majuss @suaveolent
|
||||
/tests/components/lupusec/ @majuss @suaveolent
|
||||
/homeassistant/components/lutron/ @cdheiser @wilburCForce
|
||||
/tests/components/lutron/ @cdheiser @wilburCForce
|
||||
/homeassistant/components/lutron_caseta/ @swails @bdraco @danaues
|
||||
/tests/components/lutron_caseta/ @swails @bdraco @danaues
|
||||
/homeassistant/components/lyric/ @timmo001
|
||||
@@ -766,8 +786,6 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/media_source/ @hunterjm
|
||||
/tests/components/media_source/ @hunterjm
|
||||
/homeassistant/components/mediaroom/ @dgomes
|
||||
/homeassistant/components/melcloud/ @vilppuvuorinen
|
||||
/tests/components/melcloud/ @vilppuvuorinen
|
||||
/homeassistant/components/melissa/ @kennedyshead
|
||||
/tests/components/melissa/ @kennedyshead
|
||||
/homeassistant/components/melnor/ @vanstinator
|
||||
@@ -830,6 +848,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/mysensors/ @MartinHjelmare @functionpointer
|
||||
/homeassistant/components/mystrom/ @fabaff
|
||||
/tests/components/mystrom/ @fabaff
|
||||
/homeassistant/components/myuplink/ @pajzo
|
||||
/tests/components/myuplink/ @pajzo
|
||||
/homeassistant/components/nam/ @bieniu
|
||||
/tests/components/nam/ @bieniu
|
||||
/homeassistant/components/nanoleaf/ @milanmeu
|
||||
@@ -1034,8 +1054,10 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/qvr_pro/ @oblogic7
|
||||
/homeassistant/components/qwikswitch/ @kellerza
|
||||
/tests/components/qwikswitch/ @kellerza
|
||||
/homeassistant/components/rachio/ @bdraco
|
||||
/tests/components/rachio/ @bdraco
|
||||
/homeassistant/components/rabbitair/ @rabbit-air
|
||||
/tests/components/rabbitair/ @rabbit-air
|
||||
/homeassistant/components/rachio/ @bdraco @rfverbruggen
|
||||
/tests/components/rachio/ @bdraco @rfverbruggen
|
||||
/homeassistant/components/radarr/ @tkdrob
|
||||
/tests/components/radarr/ @tkdrob
|
||||
/homeassistant/components/radio_browser/ @frenck
|
||||
@@ -1047,6 +1069,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/raincloud/ @vanstinator
|
||||
/homeassistant/components/rainforest_eagle/ @gtdiehl @jcalbert @hastarin
|
||||
/tests/components/rainforest_eagle/ @gtdiehl @jcalbert @hastarin
|
||||
/homeassistant/components/rainforest_raven/ @cottsay
|
||||
/tests/components/rainforest_raven/ @cottsay
|
||||
/homeassistant/components/rainmachine/ @bachya
|
||||
/tests/components/rainmachine/ @bachya
|
||||
/homeassistant/components/random/ @fabaff
|
||||
@@ -1099,6 +1123,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/roborock/ @humbertogontijo @Lash-L
|
||||
/homeassistant/components/roku/ @ctalkington
|
||||
/tests/components/roku/ @ctalkington
|
||||
/homeassistant/components/romy/ @xeniter
|
||||
/tests/components/romy/ @xeniter
|
||||
/homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn @Xitee1
|
||||
/tests/components/roomba/ @pschmitt @cyr-ius @shenxn @Xitee1
|
||||
/homeassistant/components/roon/ @pavoni
|
||||
@@ -1299,8 +1325,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/system_bridge/ @timmo001
|
||||
/homeassistant/components/systemmonitor/ @gjohansson-ST
|
||||
/tests/components/systemmonitor/ @gjohansson-ST
|
||||
/homeassistant/components/tado/ @michaelarnauts @chiefdragon @erwindouna
|
||||
/tests/components/tado/ @michaelarnauts @chiefdragon @erwindouna
|
||||
/homeassistant/components/tado/ @chiefdragon @erwindouna
|
||||
/tests/components/tado/ @chiefdragon @erwindouna
|
||||
/homeassistant/components/tag/ @balloob @dmulcahey
|
||||
/tests/components/tag/ @balloob @dmulcahey
|
||||
/homeassistant/components/tailscale/ @frenck
|
||||
@@ -1309,19 +1335,25 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/tailwind/ @frenck
|
||||
/homeassistant/components/tami4/ @Guy293
|
||||
/tests/components/tami4/ @Guy293
|
||||
/homeassistant/components/tankerkoenig/ @guillempages @mib1185
|
||||
/tests/components/tankerkoenig/ @guillempages @mib1185
|
||||
/homeassistant/components/tankerkoenig/ @guillempages @mib1185 @jpbede
|
||||
/tests/components/tankerkoenig/ @guillempages @mib1185 @jpbede
|
||||
/homeassistant/components/tapsaff/ @bazwilliams
|
||||
/homeassistant/components/tasmota/ @emontnemery
|
||||
/tests/components/tasmota/ @emontnemery
|
||||
/homeassistant/components/tautulli/ @ludeeus @tkdrob
|
||||
/tests/components/tautulli/ @ludeeus @tkdrob
|
||||
/homeassistant/components/technove/ @Moustachauve
|
||||
/tests/components/technove/ @Moustachauve
|
||||
/homeassistant/components/tedee/ @patrickhilker @zweckj
|
||||
/tests/components/tedee/ @patrickhilker @zweckj
|
||||
/homeassistant/components/tellduslive/ @fredrike
|
||||
/tests/components/tellduslive/ @fredrike
|
||||
/homeassistant/components/template/ @PhracturedBlue @tetienne @home-assistant/core
|
||||
/tests/components/template/ @PhracturedBlue @tetienne @home-assistant/core
|
||||
/homeassistant/components/tesla_wall_connector/ @einarhauks
|
||||
/tests/components/tesla_wall_connector/ @einarhauks
|
||||
/homeassistant/components/teslemetry/ @Bre77
|
||||
/tests/components/teslemetry/ @Bre77
|
||||
/homeassistant/components/tessie/ @Bre77
|
||||
/tests/components/tessie/ @Bre77
|
||||
/homeassistant/components/text/ @home-assistant/core
|
||||
@@ -1329,8 +1361,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/tfiac/ @fredrike @mellado
|
||||
/homeassistant/components/thermobeacon/ @bdraco
|
||||
/tests/components/thermobeacon/ @bdraco
|
||||
/homeassistant/components/thermopro/ @bdraco
|
||||
/tests/components/thermopro/ @bdraco
|
||||
/homeassistant/components/thermopro/ @bdraco @h3ss
|
||||
/tests/components/thermopro/ @bdraco @h3ss
|
||||
/homeassistant/components/thethingsnetwork/ @fabaff
|
||||
/homeassistant/components/thread/ @home-assistant/core
|
||||
/tests/components/thread/ @home-assistant/core
|
||||
@@ -1355,12 +1387,14 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/tomorrowio/ @raman325 @lymanepp
|
||||
/homeassistant/components/totalconnect/ @austinmroczek
|
||||
/tests/components/totalconnect/ @austinmroczek
|
||||
/homeassistant/components/tplink/ @rytilahti @thegardenmonkey @bdraco
|
||||
/tests/components/tplink/ @rytilahti @thegardenmonkey @bdraco
|
||||
/homeassistant/components/tplink/ @rytilahti @thegardenmonkey @bdraco @sdb9696
|
||||
/tests/components/tplink/ @rytilahti @thegardenmonkey @bdraco @sdb9696
|
||||
/homeassistant/components/tplink_omada/ @MarkGodwin
|
||||
/tests/components/tplink_omada/ @MarkGodwin
|
||||
/homeassistant/components/traccar/ @ludeeus
|
||||
/tests/components/traccar/ @ludeeus
|
||||
/homeassistant/components/traccar_server/ @ludeeus
|
||||
/tests/components/traccar_server/ @ludeeus
|
||||
/homeassistant/components/trace/ @home-assistant/core
|
||||
/tests/components/trace/ @home-assistant/core
|
||||
/homeassistant/components/tractive/ @Danielhiversen @zhulik @bieniu
|
||||
|
11
Dockerfile
11
Dockerfile
@@ -28,12 +28,19 @@ RUN \
|
||||
&& if ls homeassistant/home_assistant_intents*.whl 1> /dev/null 2>&1; then \
|
||||
pip3 install homeassistant/home_assistant_intents-*.whl; \
|
||||
fi \
|
||||
&& \
|
||||
&& if [ "${BUILD_ARCH}" = "i386" ]; then \
|
||||
LD_PRELOAD="/usr/local/lib/libjemalloc.so.2" \
|
||||
MALLOC_CONF="background_thread:true,metadata_thp:auto,dirty_decay_ms:20000,muzzy_decay_ms:20000" \
|
||||
linux32 pip3 install \
|
||||
--only-binary=:all: \
|
||||
-r homeassistant/requirements_all.txt; \
|
||||
else \
|
||||
LD_PRELOAD="/usr/local/lib/libjemalloc.so.2" \
|
||||
MALLOC_CONF="background_thread:true,metadata_thp:auto,dirty_decay_ms:20000,muzzy_decay_ms:20000" \
|
||||
pip3 install \
|
||||
--only-binary=:all: \
|
||||
-r homeassistant/requirements_all.txt
|
||||
-r homeassistant/requirements_all.txt; \
|
||||
fi
|
||||
|
||||
## Setup Home Assistant Core
|
||||
COPY . homeassistant/
|
||||
|
@@ -16,6 +16,7 @@ RUN \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
# Additional library needed by some tests and accordingly by VScode Tests Discovery
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libudev-dev \
|
||||
libavformat-dev \
|
||||
libavcodec-dev \
|
||||
|
@@ -22,7 +22,7 @@ of a component, check the `Home Assistant help section <https://home-assistant.i
|
||||
|
||||
.. |Chat Status| image:: https://img.shields.io/discord/330944238910963714.svg
|
||||
:target: https://www.home-assistant.io/join-chat/
|
||||
.. |screenshot-states| image:: https://raw.githubusercontent.com/home-assistant/core/master/docs/screenshots.png
|
||||
.. |screenshot-states| image:: https://raw.githubusercontent.com/home-assistant/core/dev/.github/assets/screenshot-states.png
|
||||
:target: https://demo.home-assistant.io
|
||||
.. |screenshot-integrations| image:: https://raw.githubusercontent.com/home-assistant/core/dev/docs/screenshot-integrations.png
|
||||
:target: https://home-assistant.io/integrations/
|
||||
.. |screenshot-integrations| image:: https://raw.githubusercontent.com/home-assistant/core/dev/.github/assets/screenshot-integrations.png
|
||||
:target: https://home-assistant.io/integrations/
|
10
build.yaml
10
build.yaml
@@ -1,10 +1,10 @@
|
||||
image: ghcr.io/home-assistant/{arch}-homeassistant
|
||||
build_from:
|
||||
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.10.1
|
||||
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.10.1
|
||||
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.10.1
|
||||
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.10.1
|
||||
i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.10.1
|
||||
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.02.0
|
||||
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.02.0
|
||||
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.02.0
|
||||
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.02.0
|
||||
i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.02.0
|
||||
codenotary:
|
||||
signer: notary@home-assistant.io
|
||||
base_image: notary@home-assistant.io
|
||||
|
@@ -4,18 +4,27 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Mapping
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
from functools import partial
|
||||
import time
|
||||
from typing import Any, cast
|
||||
|
||||
import jwt
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
HassJob,
|
||||
HassJobType,
|
||||
HomeAssistant,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers.event import async_track_point_in_utc_time
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import auth_store, jwt_wrapper, models
|
||||
from .const import ACCESS_TOKEN_EXPIRATION, GROUP_ID_ADMIN
|
||||
from .const import ACCESS_TOKEN_EXPIRATION, GROUP_ID_ADMIN, REFRESH_TOKEN_EXPIRATION
|
||||
from .mfa_modules import MultiFactorAuthModule, auth_mfa_module_from_config
|
||||
from .providers import AuthProvider, LoginFlow, auth_provider_from_config
|
||||
|
||||
@@ -47,6 +56,7 @@ async def auth_manager_from_config(
|
||||
mfa modules exist in configs.
|
||||
"""
|
||||
store = auth_store.AuthStore(hass)
|
||||
await store.async_load()
|
||||
if provider_configs:
|
||||
providers = await asyncio.gather(
|
||||
*(
|
||||
@@ -74,6 +84,7 @@ async def auth_manager_from_config(
|
||||
module_hash[module.id] = module
|
||||
|
||||
manager = AuthManager(hass, store, provider_hash, module_hash)
|
||||
manager.async_setup()
|
||||
return manager
|
||||
|
||||
|
||||
@@ -157,7 +168,22 @@ class AuthManager:
|
||||
self._providers = providers
|
||||
self._mfa_modules = mfa_modules
|
||||
self.login_flow = AuthManagerFlowManager(hass, self)
|
||||
self._revoke_callbacks: dict[str, list[CALLBACK_TYPE]] = {}
|
||||
self._revoke_callbacks: dict[str, set[CALLBACK_TYPE]] = {}
|
||||
self._expire_callback: CALLBACK_TYPE | None = None
|
||||
self._remove_expired_job = HassJob(
|
||||
self._async_remove_expired_refresh_tokens, job_type=HassJobType.Callback
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_setup(self) -> None:
|
||||
"""Set up the auth manager."""
|
||||
hass = self.hass
|
||||
hass.async_add_shutdown_job(
|
||||
HassJob(
|
||||
self._async_cancel_expiration_schedule, job_type=HassJobType.Callback
|
||||
)
|
||||
)
|
||||
self._async_track_next_refresh_token_expiration()
|
||||
|
||||
@property
|
||||
def auth_providers(self) -> list[AuthProvider]:
|
||||
@@ -423,6 +449,11 @@ class AuthManager:
|
||||
else:
|
||||
token_type = models.TOKEN_TYPE_NORMAL
|
||||
|
||||
if token_type is models.TOKEN_TYPE_NORMAL:
|
||||
expire_at = time.time() + REFRESH_TOKEN_EXPIRATION
|
||||
else:
|
||||
expire_at = None
|
||||
|
||||
if user.system_generated != (token_type == models.TOKEN_TYPE_SYSTEM):
|
||||
raise ValueError(
|
||||
"System generated users can only have system type refresh tokens"
|
||||
@@ -454,48 +485,81 @@ class AuthManager:
|
||||
client_icon,
|
||||
token_type,
|
||||
access_token_expiration,
|
||||
expire_at,
|
||||
credential,
|
||||
)
|
||||
|
||||
async def async_get_refresh_token(
|
||||
self, token_id: str
|
||||
) -> models.RefreshToken | None:
|
||||
@callback
|
||||
def async_get_refresh_token(self, token_id: str) -> models.RefreshToken | None:
|
||||
"""Get refresh token by id."""
|
||||
return await self._store.async_get_refresh_token(token_id)
|
||||
return self._store.async_get_refresh_token(token_id)
|
||||
|
||||
async def async_get_refresh_token_by_token(
|
||||
@callback
|
||||
def async_get_refresh_token_by_token(
|
||||
self, token: str
|
||||
) -> models.RefreshToken | None:
|
||||
"""Get refresh token by token."""
|
||||
return await self._store.async_get_refresh_token_by_token(token)
|
||||
return self._store.async_get_refresh_token_by_token(token)
|
||||
|
||||
async def async_remove_refresh_token(
|
||||
self, refresh_token: models.RefreshToken
|
||||
) -> None:
|
||||
@callback
|
||||
def async_remove_refresh_token(self, refresh_token: models.RefreshToken) -> None:
|
||||
"""Delete a refresh token."""
|
||||
await self._store.async_remove_refresh_token(refresh_token)
|
||||
self._store.async_remove_refresh_token(refresh_token)
|
||||
|
||||
callbacks = self._revoke_callbacks.pop(refresh_token.id, [])
|
||||
callbacks = self._revoke_callbacks.pop(refresh_token.id, ())
|
||||
for revoke_callback in callbacks:
|
||||
revoke_callback()
|
||||
|
||||
@callback
|
||||
def _async_remove_expired_refresh_tokens(self, _: datetime | None = None) -> None:
|
||||
"""Remove expired refresh tokens."""
|
||||
now = time.time()
|
||||
for token in self._store.async_get_refresh_tokens():
|
||||
if (expire_at := token.expire_at) is not None and expire_at <= now:
|
||||
self.async_remove_refresh_token(token)
|
||||
self._async_track_next_refresh_token_expiration()
|
||||
|
||||
@callback
|
||||
def _async_track_next_refresh_token_expiration(self) -> None:
|
||||
"""Initialise all token expiration scheduled tasks."""
|
||||
next_expiration = time.time() + REFRESH_TOKEN_EXPIRATION
|
||||
for token in self._store.async_get_refresh_tokens():
|
||||
if (
|
||||
expire_at := token.expire_at
|
||||
) is not None and expire_at < next_expiration:
|
||||
next_expiration = expire_at
|
||||
|
||||
self._expire_callback = async_track_point_in_utc_time(
|
||||
self.hass,
|
||||
self._remove_expired_job,
|
||||
dt_util.utc_from_timestamp(next_expiration),
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_cancel_expiration_schedule(self) -> None:
|
||||
"""Cancel tracking of expired refresh tokens."""
|
||||
if self._expire_callback:
|
||||
self._expire_callback()
|
||||
self._expire_callback = None
|
||||
|
||||
@callback
|
||||
def _async_unregister(
|
||||
self, callbacks: set[CALLBACK_TYPE], callback_: CALLBACK_TYPE
|
||||
) -> None:
|
||||
"""Unregister a callback."""
|
||||
callbacks.remove(callback_)
|
||||
|
||||
@callback
|
||||
def async_register_revoke_token_callback(
|
||||
self, refresh_token_id: str, revoke_callback: CALLBACK_TYPE
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Register a callback to be called when the refresh token id is revoked."""
|
||||
if refresh_token_id not in self._revoke_callbacks:
|
||||
self._revoke_callbacks[refresh_token_id] = []
|
||||
self._revoke_callbacks[refresh_token_id] = set()
|
||||
|
||||
callbacks = self._revoke_callbacks[refresh_token_id]
|
||||
callbacks.append(revoke_callback)
|
||||
|
||||
@callback
|
||||
def unregister() -> None:
|
||||
if revoke_callback in callbacks:
|
||||
callbacks.remove(revoke_callback)
|
||||
|
||||
return unregister
|
||||
callbacks.add(revoke_callback)
|
||||
return partial(self._async_unregister, callbacks, revoke_callback)
|
||||
|
||||
@callback
|
||||
def async_create_access_token(
|
||||
@@ -552,16 +616,15 @@ class AuthManager:
|
||||
if provider := self._async_resolve_provider(refresh_token):
|
||||
provider.async_validate_refresh_token(refresh_token, remote_ip)
|
||||
|
||||
async def async_validate_access_token(
|
||||
self, token: str
|
||||
) -> models.RefreshToken | None:
|
||||
@callback
|
||||
def async_validate_access_token(self, token: str) -> models.RefreshToken | None:
|
||||
"""Return refresh token if an access token is valid."""
|
||||
try:
|
||||
unverif_claims = jwt_wrapper.unverified_hs256_token_decode(token)
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
refresh_token = await self.async_get_refresh_token(
|
||||
refresh_token = self.async_get_refresh_token(
|
||||
cast(str, unverif_claims.get("iss"))
|
||||
)
|
||||
|
||||
|
@@ -1,10 +1,9 @@
|
||||
"""Storage for auth models."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import OrderedDict
|
||||
from datetime import timedelta
|
||||
import hmac
|
||||
import itertools
|
||||
from logging import getLogger
|
||||
from typing import Any
|
||||
|
||||
@@ -19,6 +18,7 @@ from .const import (
|
||||
GROUP_ID_ADMIN,
|
||||
GROUP_ID_READ_ONLY,
|
||||
GROUP_ID_USER,
|
||||
REFRESH_TOKEN_EXPIRATION,
|
||||
)
|
||||
from .permissions import system_policies
|
||||
from .permissions.models import PermissionLookup
|
||||
@@ -43,44 +43,28 @@ class AuthStore:
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the auth store."""
|
||||
self.hass = hass
|
||||
self._users: dict[str, models.User] | None = None
|
||||
self._groups: dict[str, models.Group] | None = None
|
||||
self._perm_lookup: PermissionLookup | None = None
|
||||
self._loaded = False
|
||||
self._users: dict[str, models.User] = None # type: ignore[assignment]
|
||||
self._groups: dict[str, models.Group] = None # type: ignore[assignment]
|
||||
self._perm_lookup: PermissionLookup = None # type: ignore[assignment]
|
||||
self._store = Store[dict[str, list[dict[str, Any]]]](
|
||||
hass, STORAGE_VERSION, STORAGE_KEY, private=True, atomic_writes=True
|
||||
)
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def async_get_groups(self) -> list[models.Group]:
|
||||
"""Retrieve all users."""
|
||||
if self._groups is None:
|
||||
await self._async_load()
|
||||
assert self._groups is not None
|
||||
|
||||
return list(self._groups.values())
|
||||
|
||||
async def async_get_group(self, group_id: str) -> models.Group | None:
|
||||
"""Retrieve all users."""
|
||||
if self._groups is None:
|
||||
await self._async_load()
|
||||
assert self._groups is not None
|
||||
|
||||
return self._groups.get(group_id)
|
||||
|
||||
async def async_get_users(self) -> list[models.User]:
|
||||
"""Retrieve all users."""
|
||||
if self._users is None:
|
||||
await self._async_load()
|
||||
assert self._users is not None
|
||||
|
||||
return list(self._users.values())
|
||||
|
||||
async def async_get_user(self, user_id: str) -> models.User | None:
|
||||
"""Retrieve a user by id."""
|
||||
if self._users is None:
|
||||
await self._async_load()
|
||||
assert self._users is not None
|
||||
|
||||
return self._users.get(user_id)
|
||||
|
||||
async def async_create_user(
|
||||
@@ -94,12 +78,6 @@ class AuthStore:
|
||||
local_only: bool | None = None,
|
||||
) -> models.User:
|
||||
"""Create a new user."""
|
||||
if self._users is None:
|
||||
await self._async_load()
|
||||
|
||||
assert self._users is not None
|
||||
assert self._groups is not None
|
||||
|
||||
groups = []
|
||||
for group_id in group_ids or []:
|
||||
if (group := self._groups.get(group_id)) is None:
|
||||
@@ -145,10 +123,6 @@ class AuthStore:
|
||||
|
||||
async def async_remove_user(self, user: models.User) -> None:
|
||||
"""Remove a user."""
|
||||
if self._users is None:
|
||||
await self._async_load()
|
||||
assert self._users is not None
|
||||
|
||||
self._users.pop(user.id)
|
||||
self._async_schedule_save()
|
||||
|
||||
@@ -161,8 +135,6 @@ class AuthStore:
|
||||
local_only: bool | None = None,
|
||||
) -> None:
|
||||
"""Update a user."""
|
||||
assert self._groups is not None
|
||||
|
||||
if group_ids is not None:
|
||||
groups = []
|
||||
for grid in group_ids:
|
||||
@@ -171,7 +143,6 @@ class AuthStore:
|
||||
groups.append(group)
|
||||
|
||||
user.groups = groups
|
||||
user.invalidate_permission_cache()
|
||||
|
||||
for attr_name, value in (
|
||||
("name", name),
|
||||
@@ -195,10 +166,6 @@ class AuthStore:
|
||||
|
||||
async def async_remove_credentials(self, credentials: models.Credentials) -> None:
|
||||
"""Remove credentials."""
|
||||
if self._users is None:
|
||||
await self._async_load()
|
||||
assert self._users is not None
|
||||
|
||||
for user in self._users.values():
|
||||
found = None
|
||||
|
||||
@@ -221,6 +188,7 @@ class AuthStore:
|
||||
client_icon: str | None = None,
|
||||
token_type: str = models.TOKEN_TYPE_NORMAL,
|
||||
access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION,
|
||||
expire_at: float | None = None,
|
||||
credential: models.Credentials | None = None,
|
||||
) -> models.RefreshToken:
|
||||
"""Create a new token for a user."""
|
||||
@@ -229,6 +197,7 @@ class AuthStore:
|
||||
"client_id": client_id,
|
||||
"token_type": token_type,
|
||||
"access_token_expiration": access_token_expiration,
|
||||
"expire_at": expire_at,
|
||||
"credential": credential,
|
||||
}
|
||||
if client_name:
|
||||
@@ -242,27 +211,17 @@ class AuthStore:
|
||||
self._async_schedule_save()
|
||||
return refresh_token
|
||||
|
||||
async def async_remove_refresh_token(
|
||||
self, refresh_token: models.RefreshToken
|
||||
) -> None:
|
||||
@callback
|
||||
def async_remove_refresh_token(self, refresh_token: models.RefreshToken) -> None:
|
||||
"""Remove a refresh token."""
|
||||
if self._users is None:
|
||||
await self._async_load()
|
||||
assert self._users is not None
|
||||
|
||||
for user in self._users.values():
|
||||
if user.refresh_tokens.pop(refresh_token.id, None):
|
||||
self._async_schedule_save()
|
||||
break
|
||||
|
||||
async def async_get_refresh_token(
|
||||
self, token_id: str
|
||||
) -> models.RefreshToken | None:
|
||||
@callback
|
||||
def async_get_refresh_token(self, token_id: str) -> models.RefreshToken | None:
|
||||
"""Get refresh token by id."""
|
||||
if self._users is None:
|
||||
await self._async_load()
|
||||
assert self._users is not None
|
||||
|
||||
for user in self._users.values():
|
||||
refresh_token = user.refresh_tokens.get(token_id)
|
||||
if refresh_token is not None:
|
||||
@@ -270,14 +229,11 @@ class AuthStore:
|
||||
|
||||
return None
|
||||
|
||||
async def async_get_refresh_token_by_token(
|
||||
@callback
|
||||
def async_get_refresh_token_by_token(
|
||||
self, token: str
|
||||
) -> models.RefreshToken | None:
|
||||
"""Get refresh token by token."""
|
||||
if self._users is None:
|
||||
await self._async_load()
|
||||
assert self._users is not None
|
||||
|
||||
found = None
|
||||
|
||||
for user in self._users.values():
|
||||
@@ -287,6 +243,15 @@ class AuthStore:
|
||||
|
||||
return found
|
||||
|
||||
@callback
|
||||
def async_get_refresh_tokens(self) -> list[models.RefreshToken]:
|
||||
"""Get all refresh tokens."""
|
||||
return list(
|
||||
itertools.chain.from_iterable(
|
||||
user.refresh_tokens.values() for user in self._users.values()
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_log_refresh_token_usage(
|
||||
self, refresh_token: models.RefreshToken, remote_ip: str | None = None
|
||||
@@ -294,35 +259,34 @@ class AuthStore:
|
||||
"""Update refresh token last used information."""
|
||||
refresh_token.last_used_at = dt_util.utcnow()
|
||||
refresh_token.last_used_ip = remote_ip
|
||||
if refresh_token.expire_at:
|
||||
refresh_token.expire_at = (
|
||||
refresh_token.last_used_at.timestamp() + REFRESH_TOKEN_EXPIRATION
|
||||
)
|
||||
self._async_schedule_save()
|
||||
|
||||
async def _async_load(self) -> None:
|
||||
async def async_load(self) -> None: # noqa: C901
|
||||
"""Load the users."""
|
||||
async with self._lock:
|
||||
if self._users is not None:
|
||||
return
|
||||
await self._async_load_task()
|
||||
if self._loaded:
|
||||
raise RuntimeError("Auth storage is already loaded")
|
||||
self._loaded = True
|
||||
|
||||
async def _async_load_task(self) -> None:
|
||||
"""Load the users."""
|
||||
dev_reg = dr.async_get(self.hass)
|
||||
ent_reg = er.async_get(self.hass)
|
||||
data = await self._store.async_load()
|
||||
|
||||
# Make sure that we're not overriding data if 2 loads happened at the
|
||||
# same time
|
||||
if self._users is not None:
|
||||
return
|
||||
perm_lookup = PermissionLookup(ent_reg, dev_reg)
|
||||
self._perm_lookup = perm_lookup
|
||||
|
||||
self._perm_lookup = perm_lookup = PermissionLookup(ent_reg, dev_reg)
|
||||
now_ts = dt_util.utcnow().timestamp()
|
||||
|
||||
if data is None or not isinstance(data, dict):
|
||||
self._set_defaults()
|
||||
return
|
||||
|
||||
users: dict[str, models.User] = OrderedDict()
|
||||
groups: dict[str, models.Group] = OrderedDict()
|
||||
credentials: dict[str, models.Credentials] = OrderedDict()
|
||||
users: dict[str, models.User] = {}
|
||||
groups: dict[str, models.Group] = {}
|
||||
credentials: dict[str, models.Credentials] = {}
|
||||
|
||||
# Soft-migrating data as we load. We are going to make sure we have a
|
||||
# read only group and an admin group. There are two states that we can
|
||||
@@ -469,6 +433,14 @@ class AuthStore:
|
||||
else:
|
||||
last_used_at = None
|
||||
|
||||
if (
|
||||
expire_at := rt_dict.get("expire_at")
|
||||
) is None and token_type == models.TOKEN_TYPE_NORMAL:
|
||||
if last_used_at:
|
||||
expire_at = last_used_at.timestamp() + REFRESH_TOKEN_EXPIRATION
|
||||
else:
|
||||
expire_at = now_ts + REFRESH_TOKEN_EXPIRATION
|
||||
|
||||
token = models.RefreshToken(
|
||||
id=rt_dict["id"],
|
||||
user=users[rt_dict["user_id"]],
|
||||
@@ -485,6 +457,7 @@ class AuthStore:
|
||||
jwt_key=rt_dict["jwt_key"],
|
||||
last_used_at=last_used_at,
|
||||
last_used_ip=rt_dict.get("last_used_ip"),
|
||||
expire_at=expire_at,
|
||||
version=rt_dict.get("version"),
|
||||
)
|
||||
if "credential_id" in rt_dict:
|
||||
@@ -494,20 +467,16 @@ class AuthStore:
|
||||
self._groups = groups
|
||||
self._users = users
|
||||
|
||||
self._async_schedule_save()
|
||||
|
||||
@callback
|
||||
def _async_schedule_save(self) -> None:
|
||||
"""Save users."""
|
||||
if self._users is None:
|
||||
return
|
||||
|
||||
self._store.async_delay_save(self._data_to_save, 1)
|
||||
|
||||
@callback
|
||||
def _data_to_save(self) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Return the data to store."""
|
||||
assert self._users is not None
|
||||
assert self._groups is not None
|
||||
|
||||
users = [
|
||||
{
|
||||
"id": user.id,
|
||||
@@ -564,6 +533,7 @@ class AuthStore:
|
||||
if refresh_token.last_used_at
|
||||
else None,
|
||||
"last_used_ip": refresh_token.last_used_ip,
|
||||
"expire_at": refresh_token.expire_at,
|
||||
"credential_id": refresh_token.credential.id
|
||||
if refresh_token.credential
|
||||
else None,
|
||||
@@ -582,9 +552,9 @@ class AuthStore:
|
||||
|
||||
def _set_defaults(self) -> None:
|
||||
"""Set default values for auth store."""
|
||||
self._users = OrderedDict()
|
||||
self._users = {}
|
||||
|
||||
groups: dict[str, models.Group] = OrderedDict()
|
||||
groups: dict[str, models.Group] = {}
|
||||
admin_group = _system_admin_group()
|
||||
groups[admin_group.id] = admin_group
|
||||
user_group = _system_user_group()
|
||||
|
@@ -3,6 +3,7 @@ from datetime import timedelta
|
||||
|
||||
ACCESS_TOKEN_EXPIRATION = timedelta(minutes=30)
|
||||
MFA_SESSION_EXPIRATION = timedelta(minutes=5)
|
||||
REFRESH_TOKEN_EXPIRATION = timedelta(days=90).total_seconds()
|
||||
|
||||
GROUP_ID_ADMIN = "system-admin"
|
||||
GROUP_ID_USER = "system-users"
|
||||
|
@@ -152,7 +152,7 @@ class NotifyAuthModule(MultiFactorAuthModule):
|
||||
"""Return list of notify services."""
|
||||
unordered_services = set()
|
||||
|
||||
for service in self.hass.services.async_services().get("notify", {}):
|
||||
for service in self.hass.services.async_services_for_domain("notify"):
|
||||
if service not in self._exclude:
|
||||
unordered_services.add(service)
|
||||
|
||||
|
@@ -3,10 +3,12 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import secrets
|
||||
from typing import NamedTuple
|
||||
from typing import TYPE_CHECKING, Any, NamedTuple
|
||||
import uuid
|
||||
|
||||
import attr
|
||||
from attr import Attribute
|
||||
from attr.setters import validate
|
||||
|
||||
from homeassistant.const import __version__
|
||||
from homeassistant.util import dt as dt_util
|
||||
@@ -14,6 +16,12 @@ from homeassistant.util import dt as dt_util
|
||||
from . import permissions as perm_mdl
|
||||
from .const import GROUP_ID_ADMIN
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from functools import cached_property
|
||||
else:
|
||||
from homeassistant.backports.functools import cached_property
|
||||
|
||||
|
||||
TOKEN_TYPE_NORMAL = "normal"
|
||||
TOKEN_TYPE_SYSTEM = "system"
|
||||
TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN = "long_lived_access_token"
|
||||
@@ -29,19 +37,27 @@ class Group:
|
||||
system_generated: bool = attr.ib(default=False)
|
||||
|
||||
|
||||
@attr.s(slots=True)
|
||||
def _handle_permissions_change(self: User, user_attr: Attribute, new: Any) -> Any:
|
||||
"""Handle a change to a permissions."""
|
||||
self.invalidate_cache()
|
||||
return validate(self, user_attr, new)
|
||||
|
||||
|
||||
@attr.s(slots=False)
|
||||
class User:
|
||||
"""A user."""
|
||||
|
||||
name: str | None = attr.ib()
|
||||
perm_lookup: perm_mdl.PermissionLookup = attr.ib(eq=False, order=False)
|
||||
id: str = attr.ib(factory=lambda: uuid.uuid4().hex)
|
||||
is_owner: bool = attr.ib(default=False)
|
||||
is_active: bool = attr.ib(default=False)
|
||||
is_owner: bool = attr.ib(default=False, on_setattr=_handle_permissions_change)
|
||||
is_active: bool = attr.ib(default=False, on_setattr=_handle_permissions_change)
|
||||
system_generated: bool = attr.ib(default=False)
|
||||
local_only: bool = attr.ib(default=False)
|
||||
|
||||
groups: list[Group] = attr.ib(factory=list, eq=False, order=False)
|
||||
groups: list[Group] = attr.ib(
|
||||
factory=list, eq=False, order=False, on_setattr=_handle_permissions_change
|
||||
)
|
||||
|
||||
# List of credentials of a user.
|
||||
credentials: list[Credentials] = attr.ib(factory=list, eq=False, order=False)
|
||||
@@ -51,40 +67,31 @@ class User:
|
||||
factory=dict, eq=False, order=False
|
||||
)
|
||||
|
||||
_permissions: perm_mdl.PolicyPermissions | None = attr.ib(
|
||||
init=False,
|
||||
eq=False,
|
||||
order=False,
|
||||
default=None,
|
||||
)
|
||||
|
||||
@property
|
||||
@cached_property
|
||||
def permissions(self) -> perm_mdl.AbstractPermissions:
|
||||
"""Return permissions object for user."""
|
||||
if self.is_owner:
|
||||
return perm_mdl.OwnerPermissions
|
||||
|
||||
if self._permissions is not None:
|
||||
return self._permissions
|
||||
|
||||
self._permissions = perm_mdl.PolicyPermissions(
|
||||
return perm_mdl.PolicyPermissions(
|
||||
perm_mdl.merge_policies([group.policy for group in self.groups]),
|
||||
self.perm_lookup,
|
||||
)
|
||||
|
||||
return self._permissions
|
||||
|
||||
@property
|
||||
@cached_property
|
||||
def is_admin(self) -> bool:
|
||||
"""Return if user is part of the admin group."""
|
||||
if self.is_owner:
|
||||
return True
|
||||
return self.is_owner or (
|
||||
self.is_active and any(gr.id == GROUP_ID_ADMIN for gr in self.groups)
|
||||
)
|
||||
|
||||
return self.is_active and any(gr.id == GROUP_ID_ADMIN for gr in self.groups)
|
||||
|
||||
def invalidate_permission_cache(self) -> None:
|
||||
"""Invalidate permission cache."""
|
||||
self._permissions = None
|
||||
def invalidate_cache(self) -> None:
|
||||
"""Invalidate permission and is_admin cache."""
|
||||
for attr_to_invalidate in ("permissions", "is_admin"):
|
||||
# try is must more efficient than suppress
|
||||
try: # noqa: SIM105
|
||||
delattr(self, attr_to_invalidate)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
|
||||
@attr.s(slots=True)
|
||||
@@ -110,6 +117,8 @@ class RefreshToken:
|
||||
last_used_at: datetime | None = attr.ib(default=None)
|
||||
last_used_ip: str | None = attr.ib(default=None)
|
||||
|
||||
expire_at: float | None = attr.ib(default=None)
|
||||
|
||||
credential: Credentials | None = attr.ib(default=None)
|
||||
|
||||
version: str | None = attr.ib(default=__version__)
|
||||
|
@@ -16,7 +16,7 @@ from typing import TYPE_CHECKING, Any
|
||||
import voluptuous as vol
|
||||
import yarl
|
||||
|
||||
from . import config as conf_util, config_entries, core, loader
|
||||
from . import config as conf_util, config_entries, core, loader, requirements
|
||||
from .components import http
|
||||
from .const import (
|
||||
FORMAT_DATETIME,
|
||||
@@ -39,7 +39,6 @@ from .helpers import (
|
||||
from .helpers.dispatcher import async_dispatcher_send
|
||||
from .helpers.typing import ConfigType
|
||||
from .setup import (
|
||||
DATA_SETUP,
|
||||
DATA_SETUP_STARTED,
|
||||
DATA_SETUP_TIME,
|
||||
async_notify_setup_error,
|
||||
@@ -106,6 +105,52 @@ STAGE_1_INTEGRATIONS = {
|
||||
# Ensure supervisor is available
|
||||
"hassio",
|
||||
}
|
||||
DEFAULT_INTEGRATIONS = {
|
||||
# These integrations are set up unless recovery mode is activated.
|
||||
#
|
||||
# Integrations providing core functionality:
|
||||
"application_credentials",
|
||||
"frontend",
|
||||
"hardware",
|
||||
"logger",
|
||||
"network",
|
||||
"system_health",
|
||||
#
|
||||
# Key-feature:
|
||||
"automation",
|
||||
"person",
|
||||
"scene",
|
||||
"script",
|
||||
"tag",
|
||||
"zone",
|
||||
#
|
||||
# Built-in helpers:
|
||||
"counter",
|
||||
"input_boolean",
|
||||
"input_button",
|
||||
"input_datetime",
|
||||
"input_number",
|
||||
"input_select",
|
||||
"input_text",
|
||||
"schedule",
|
||||
"timer",
|
||||
}
|
||||
DEFAULT_INTEGRATIONS_RECOVERY_MODE = {
|
||||
# These integrations are set up if recovery mode is activated.
|
||||
"frontend",
|
||||
}
|
||||
DEFAULT_INTEGRATIONS_SUPERVISOR = {
|
||||
# These integrations are set up if using the Supervisor
|
||||
"hassio",
|
||||
}
|
||||
DEFAULT_INTEGRATIONS_NON_SUPERVISOR = {
|
||||
# These integrations are set up if not using the Supervisor
|
||||
"backup",
|
||||
}
|
||||
CRITICAL_INTEGRATIONS = {
|
||||
# Recovery mode is activated if these integrations fail to set up
|
||||
"frontend",
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_hass(
|
||||
@@ -165,11 +210,11 @@ async def async_setup_hass(
|
||||
_LOGGER.warning("Unable to set up core integrations. Activating recovery mode")
|
||||
recovery_mode = True
|
||||
|
||||
elif (
|
||||
"frontend" in hass.data.get(DATA_SETUP, {})
|
||||
and "frontend" not in hass.config.components
|
||||
):
|
||||
_LOGGER.warning("Detected that frontend did not load. Activating recovery mode")
|
||||
elif any(domain not in hass.config.components for domain in CRITICAL_INTEGRATIONS):
|
||||
_LOGGER.warning(
|
||||
"Detected that %s did not load. Activating recovery mode",
|
||||
",".join(CRITICAL_INTEGRATIONS),
|
||||
)
|
||||
# Ask integrations to shut down. It's messy but we can't
|
||||
# do a clean stop without knowing what is broken
|
||||
with contextlib.suppress(asyncio.TimeoutError):
|
||||
@@ -229,7 +274,7 @@ def open_hass_ui(hass: core.HomeAssistant) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def load_registries(hass: core.HomeAssistant) -> None:
|
||||
async def async_load_base_functionality(hass: core.HomeAssistant) -> None:
|
||||
"""Load the registries and cache the result of platform.uname().processor."""
|
||||
if DATA_REGISTRIES_LOADED in hass.data:
|
||||
return
|
||||
@@ -256,6 +301,7 @@ async def load_registries(hass: core.HomeAssistant) -> None:
|
||||
hass.async_add_executor_job(_cache_uname_processor),
|
||||
template.async_load_custom_templates(hass),
|
||||
restore_state.async_load(hass),
|
||||
hass.config_entries.async_initialize(),
|
||||
)
|
||||
|
||||
|
||||
@@ -270,8 +316,7 @@ async def async_from_config_dict(
|
||||
start = monotonic()
|
||||
|
||||
hass.config_entries = config_entries.ConfigEntries(hass, config)
|
||||
await hass.config_entries.async_initialize()
|
||||
await load_registries(hass)
|
||||
await async_load_base_functionality(hass)
|
||||
|
||||
# Set up core.
|
||||
_LOGGER.debug("Setting up %s", CORE_INTEGRATIONS)
|
||||
@@ -478,13 +523,18 @@ def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]:
|
||||
domain for key in config if (domain := cv.domain_key(key)) != core.DOMAIN
|
||||
}
|
||||
|
||||
# Add config entry domains
|
||||
# Add config entry and default domains
|
||||
if not hass.config.recovery_mode:
|
||||
domains.update(DEFAULT_INTEGRATIONS)
|
||||
domains.update(hass.config_entries.async_domains())
|
||||
else:
|
||||
domains.update(DEFAULT_INTEGRATIONS_RECOVERY_MODE)
|
||||
|
||||
# Make sure the Hass.io component is loaded
|
||||
# Add domains depending on if the Supervisor is used or not
|
||||
if "SUPERVISOR" in os.environ:
|
||||
domains.add("hassio")
|
||||
domains.update(DEFAULT_INTEGRATIONS_SUPERVISOR)
|
||||
else:
|
||||
domains.update(DEFAULT_INTEGRATIONS_NON_SUPERVISOR)
|
||||
|
||||
return domains
|
||||
|
||||
@@ -527,11 +577,13 @@ async def async_setup_multi_components(
|
||||
config: dict[str, Any],
|
||||
) -> None:
|
||||
"""Set up multiple domains. Log on failure."""
|
||||
# Avoid creating tasks for domains that were setup in a previous stage
|
||||
domains_not_yet_setup = domains - hass.config.components
|
||||
futures = {
|
||||
domain: hass.async_create_task(
|
||||
async_setup_component(hass, domain, config), f"setup component {domain}"
|
||||
)
|
||||
for domain in domains
|
||||
for domain in domains_not_yet_setup
|
||||
}
|
||||
results = await asyncio.gather(*futures.values(), return_exceptions=True)
|
||||
for idx, domain in enumerate(futures):
|
||||
@@ -555,6 +607,8 @@ async def _async_set_up_integrations(
|
||||
|
||||
domains_to_setup = _get_domains(hass, config)
|
||||
|
||||
needed_requirements: set[str] = set()
|
||||
|
||||
# Resolve all dependencies so we know all integrations
|
||||
# that will have to be loaded and start rightaway
|
||||
integration_cache: dict[str, loader.Integration] = {}
|
||||
@@ -570,6 +624,25 @@ async def _async_set_up_integrations(
|
||||
).values()
|
||||
if isinstance(int_or_exc, loader.Integration)
|
||||
]
|
||||
|
||||
manifest_deps: set[str] = set()
|
||||
for itg in integrations_to_process:
|
||||
manifest_deps.update(itg.dependencies)
|
||||
manifest_deps.update(itg.after_dependencies)
|
||||
needed_requirements.update(itg.requirements)
|
||||
|
||||
if manifest_deps:
|
||||
# If there are dependencies, try to preload all
|
||||
# the integrations manifest at once and add them
|
||||
# to the list of requirements we need to install
|
||||
# so we can try to check if they are already installed
|
||||
# in a single call below which avoids each integration
|
||||
# having to wait for the lock to do it individually
|
||||
deps = await loader.async_get_integrations(hass, manifest_deps)
|
||||
for dependant_itg in deps.values():
|
||||
if isinstance(dependant_itg, loader.Integration):
|
||||
needed_requirements.update(dependant_itg.requirements)
|
||||
|
||||
resolve_dependencies_tasks = [
|
||||
itg.resolve_dependencies()
|
||||
for itg in integrations_to_process
|
||||
@@ -591,6 +664,14 @@ async def _async_set_up_integrations(
|
||||
|
||||
_LOGGER.info("Domains to be set up: %s", domains_to_setup)
|
||||
|
||||
# Optimistically check if requirements are already installed
|
||||
# ahead of setting up the integrations so we can prime the cache
|
||||
# We do not wait for this since its an optimization only
|
||||
hass.async_create_background_task(
|
||||
requirements.async_load_installed_versions(hass, needed_requirements),
|
||||
"check installed requirements",
|
||||
)
|
||||
|
||||
# Initialize recorder
|
||||
if "recorder" in domains_to_setup:
|
||||
recorder.async_initialize_recorder(hass)
|
||||
|
5
homeassistant/brands/govee.json
Normal file
5
homeassistant/brands/govee.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "govee",
|
||||
"name": "Govee",
|
||||
"integrations": ["govee_ble", "govee_light_local"]
|
||||
}
|
5
homeassistant/brands/rainforest.json
Normal file
5
homeassistant/brands/rainforest.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "rainforest_automation",
|
||||
"name": "Rainforest Automation",
|
||||
"integrations": ["rainforest_eagle", "rainforest_raven"]
|
||||
}
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"domain": "tplink",
|
||||
"name": "TP-Link",
|
||||
"integrations": ["tplink", "tplink_omada", "tplink_lte"],
|
||||
"integrations": ["tplink", "tplink_omada", "tplink_lte", "tplink_tapo"],
|
||||
"iot_standards": ["matter"]
|
||||
}
|
||||
|
5
homeassistant/brands/traccar.json
Normal file
5
homeassistant/brands/traccar.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "traccar",
|
||||
"name": "Traccar",
|
||||
"integrations": ["traccar", "traccar_server"]
|
||||
}
|
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
|
||||
from homeassistant.core import HomeAssistant, split_entity_id
|
||||
from homeassistant.helpers.group import expand_entity_ids
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -21,7 +22,7 @@ def is_on(hass: HomeAssistant, entity_id: str | None = None) -> bool:
|
||||
If there is no entity id given we will check all.
|
||||
"""
|
||||
if entity_id:
|
||||
entity_ids = hass.components.group.expand_entity_ids([entity_id])
|
||||
entity_ids = expand_entity_ids(hass, [entity_id])
|
||||
else:
|
||||
entity_ids = hass.states.entity_ids()
|
||||
|
||||
|
@@ -63,12 +63,12 @@ AUTOMATION_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids})
|
||||
PLATFORMS = [
|
||||
Platform.ALARM_CONTROL_PANEL,
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.LOCK,
|
||||
Platform.SWITCH,
|
||||
Platform.COVER,
|
||||
Platform.CAMERA,
|
||||
Platform.COVER,
|
||||
Platform.LIGHT,
|
||||
Platform.LOCK,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
||||
|
||||
|
@@ -17,8 +17,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from . import AbodeDevice, AbodeSystem
|
||||
from .const import DOMAIN
|
||||
|
||||
ICON = "mdi:security"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
@@ -33,7 +31,6 @@ async def async_setup_entry(
|
||||
class AbodeAlarm(AbodeDevice, alarm.AlarmControlPanelEntity):
|
||||
"""An alarm_control_panel implementation for Abode."""
|
||||
|
||||
_attr_icon = ICON
|
||||
_attr_name = None
|
||||
_attr_code_arm_required = False
|
||||
_attr_supported_features = (
|
||||
|
9
homeassistant/components/abode/icons.json
Normal file
9
homeassistant/components/abode/icons.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"entity": {
|
||||
"switch": {
|
||||
"automation": {
|
||||
"default": "mdi:robot"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -17,8 +17,6 @@ from .const import DOMAIN
|
||||
|
||||
DEVICE_TYPES = [CONST.TYPE_SWITCH, CONST.TYPE_VALVE]
|
||||
|
||||
ICON = "mdi:robot"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
@@ -63,7 +61,7 @@ class AbodeSwitch(AbodeDevice, SwitchEntity):
|
||||
class AbodeAutomationSwitch(AbodeAutomation, SwitchEntity):
|
||||
"""A switch implementation for Abode automations."""
|
||||
|
||||
_attr_icon = ICON
|
||||
_attr_translation_key = "automation"
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Set up trigger automation service."""
|
||||
|
@@ -75,7 +75,7 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
class AccuWeatherDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
class AccuWeatherDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # pylint: disable=hass-enforce-coordinator-module
|
||||
"""Class to manage fetching AccuWeather data API."""
|
||||
|
||||
def __init__(
|
||||
|
@@ -66,12 +66,12 @@ class AcmedaBase(entity.Entity):
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Return the unique ID of this roller."""
|
||||
return self.roller.id
|
||||
return self.roller.id # type: ignore[no-any-return]
|
||||
|
||||
@property
|
||||
def device_id(self) -> str:
|
||||
"""Return the ID of this roller."""
|
||||
return self.roller.id
|
||||
return self.roller.id # type: ignore[no-any-return]
|
||||
|
||||
@property
|
||||
def device_info(self) -> dr.DeviceInfo:
|
||||
|
@@ -30,7 +30,7 @@ async def async_setup_entry(
|
||||
current: set[int] = set()
|
||||
|
||||
@callback
|
||||
def async_add_acmeda_covers():
|
||||
def async_add_acmeda_covers() -> None:
|
||||
async_add_acmeda_entities(
|
||||
hass, AcmedaCover, config_entry, current, async_add_entities
|
||||
)
|
||||
@@ -95,7 +95,7 @@ class AcmedaCover(AcmedaBase, CoverEntity):
|
||||
@property
|
||||
def is_closed(self) -> bool:
|
||||
"""Return if the cover is closed."""
|
||||
return self.roller.closed_percent == 100
|
||||
return self.roller.closed_percent == 100 # type: ignore[no-any-return]
|
||||
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Close the roller."""
|
||||
|
@@ -1,6 +1,8 @@
|
||||
"""Helper functions for Acmeda Pulse."""
|
||||
from __future__ import annotations
|
||||
|
||||
from aiopulse import Roller
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
@@ -16,7 +18,7 @@ def async_add_acmeda_entities(
|
||||
config_entry: ConfigEntry,
|
||||
current: set[int],
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
):
|
||||
) -> None:
|
||||
"""Add any new entities."""
|
||||
hub = hass.data[DOMAIN][config_entry.entry_id]
|
||||
LOGGER.debug("Looking for new %s on: %s", entity_class.__name__, hub.host)
|
||||
@@ -34,7 +36,9 @@ def async_add_acmeda_entities(
|
||||
async_add_entities(new_items)
|
||||
|
||||
|
||||
async def update_devices(hass: HomeAssistant, config_entry: ConfigEntry, api):
|
||||
async def update_devices(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry, api: dict[int, Roller]
|
||||
) -> None:
|
||||
"""Tell hass that device info has been updated."""
|
||||
dev_registry = dr.async_get(hass)
|
||||
|
||||
|
@@ -2,9 +2,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
|
||||
import aiopulse
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
|
||||
from .const import ACMEDA_ENTITY_REMOVE, ACMEDA_HUB_UPDATE, LOGGER
|
||||
@@ -14,31 +17,29 @@ from .helpers import update_devices
|
||||
class PulseHub:
|
||||
"""Manages a single Pulse Hub."""
|
||||
|
||||
def __init__(self, hass, config_entry):
|
||||
api: aiopulse.Hub
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
|
||||
"""Initialize the system."""
|
||||
self.config_entry = config_entry
|
||||
self.hass = hass
|
||||
self.api: aiopulse.Hub | None = None
|
||||
self.tasks = []
|
||||
self.current_rollers = {}
|
||||
self.cleanup_callbacks = []
|
||||
self.tasks: list[asyncio.Task[None]] = []
|
||||
self.current_rollers: dict[int, aiopulse.Roller] = {}
|
||||
self.cleanup_callbacks: list[Callable[[], None]] = []
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
def title(self) -> str:
|
||||
"""Return the title of the hub shown in the integrations list."""
|
||||
return f"{self.api.id} ({self.api.host})"
|
||||
|
||||
@property
|
||||
def host(self):
|
||||
def host(self) -> str:
|
||||
"""Return the host of this hub."""
|
||||
return self.config_entry.data["host"]
|
||||
return self.config_entry.data["host"] # type: ignore[no-any-return]
|
||||
|
||||
async def async_setup(self, tries=0):
|
||||
async def async_setup(self, tries: int = 0) -> bool:
|
||||
"""Set up a hub based on host parameter."""
|
||||
host = self.host
|
||||
|
||||
hub = aiopulse.Hub(host)
|
||||
self.api = hub
|
||||
self.api = hub = aiopulse.Hub(self.host)
|
||||
|
||||
hub.callback_subscribe(self.async_notify_update)
|
||||
self.tasks.append(asyncio.create_task(hub.run()))
|
||||
@@ -46,7 +47,7 @@ class PulseHub:
|
||||
LOGGER.debug("Hub setup complete")
|
||||
return True
|
||||
|
||||
async def async_reset(self):
|
||||
async def async_reset(self) -> bool:
|
||||
"""Reset this hub to default state."""
|
||||
|
||||
for cleanup_callback in self.cleanup_callbacks:
|
||||
@@ -66,7 +67,7 @@ class PulseHub:
|
||||
|
||||
return True
|
||||
|
||||
async def async_notify_update(self, update_type):
|
||||
async def async_notify_update(self, update_type: aiopulse.UpdateType) -> None:
|
||||
"""Evaluate entities when hub reports that update has occurred."""
|
||||
LOGGER.debug("Hub {update_type.name} updated")
|
||||
|
||||
|
@@ -25,7 +25,7 @@ async def async_setup_entry(
|
||||
current: set[int] = set()
|
||||
|
||||
@callback
|
||||
def async_add_acmeda_sensors():
|
||||
def async_add_acmeda_sensors() -> None:
|
||||
async_add_acmeda_entities(
|
||||
hass, AcmedaBattery, config_entry, current, async_add_entities
|
||||
)
|
||||
@@ -48,4 +48,4 @@ class AcmedaBattery(AcmedaBase, SensorEntity):
|
||||
@property
|
||||
def native_value(self) -> float | int | None:
|
||||
"""Return the state of the device."""
|
||||
return self.roller.battery
|
||||
return self.roller.battery # type: ignore[no-any-return]
|
||||
|
@@ -67,9 +67,14 @@ class AdaxDevice(ClimateEntity):
|
||||
_attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF]
|
||||
_attr_max_temp = 35
|
||||
_attr_min_temp = 5
|
||||
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
)
|
||||
_attr_target_temperature_step = PRECISION_WHOLE
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
|
||||
def __init__(self, heater_data: dict[str, Any], adax_data_handler: Adax) -> None:
|
||||
"""Initialize the heater."""
|
||||
|
75
homeassistant/components/adguard/icons.json
Normal file
75
homeassistant/components/adguard/icons.json
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"dns_queries": {
|
||||
"default": "mdi:magnify"
|
||||
},
|
||||
"dns_queries_blocked": {
|
||||
"default": "mdi:magnify-close"
|
||||
},
|
||||
"dns_queries_blocked_ratio": {
|
||||
"default": "mdi:magnify-close"
|
||||
},
|
||||
"parental_control_blocked": {
|
||||
"default": "mdi:human-male-girl"
|
||||
},
|
||||
"safe_browsing_blocked": {
|
||||
"default": "mdi:shield-half-full"
|
||||
},
|
||||
"safe_searches_enforced": {
|
||||
"default": "mdi:shield-search"
|
||||
},
|
||||
"average_processing_speed": {
|
||||
"default": "mdi:speedometer"
|
||||
},
|
||||
"rules_count": {
|
||||
"default": "mdi:counter"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"protection": {
|
||||
"default": "mdi:shield-check",
|
||||
"state": {
|
||||
"off": "mdi:shield-off"
|
||||
}
|
||||
},
|
||||
"parental": {
|
||||
"default": "mdi:shield-check",
|
||||
"state": {
|
||||
"off": "mdi:shield-off"
|
||||
}
|
||||
},
|
||||
"safe_search": {
|
||||
"default": "mdi:shield-check",
|
||||
"state": {
|
||||
"off": "mdi:shield-off"
|
||||
}
|
||||
},
|
||||
"safe_browsing": {
|
||||
"default": "mdi:shield-check",
|
||||
"state": {
|
||||
"off": "mdi:shield-off"
|
||||
}
|
||||
},
|
||||
"filtering": {
|
||||
"default": "mdi:shield-check",
|
||||
"state": {
|
||||
"off": "mdi:shield-off"
|
||||
}
|
||||
},
|
||||
"query_log": {
|
||||
"default": "mdi:shield-check",
|
||||
"state": {
|
||||
"off": "mdi:shield-off"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"add_url": "mdi:link-plus",
|
||||
"remove_url": "mdi:link-off",
|
||||
"enable_url": "mdi:link-variant",
|
||||
"disable_url": "mdi:link-variant-off",
|
||||
"refresh": "mdi:refresh"
|
||||
}
|
||||
}
|
@@ -33,56 +33,48 @@ SENSORS: tuple[AdGuardHomeEntityDescription, ...] = (
|
||||
AdGuardHomeEntityDescription(
|
||||
key="dns_queries",
|
||||
translation_key="dns_queries",
|
||||
icon="mdi:magnify",
|
||||
native_unit_of_measurement="queries",
|
||||
value_fn=lambda adguard: adguard.stats.dns_queries(),
|
||||
),
|
||||
AdGuardHomeEntityDescription(
|
||||
key="blocked_filtering",
|
||||
translation_key="dns_queries_blocked",
|
||||
icon="mdi:magnify-close",
|
||||
native_unit_of_measurement="queries",
|
||||
value_fn=lambda adguard: adguard.stats.blocked_filtering(),
|
||||
),
|
||||
AdGuardHomeEntityDescription(
|
||||
key="blocked_percentage",
|
||||
translation_key="dns_queries_blocked_ratio",
|
||||
icon="mdi:magnify-close",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
value_fn=lambda adguard: adguard.stats.blocked_percentage(),
|
||||
),
|
||||
AdGuardHomeEntityDescription(
|
||||
key="blocked_parental",
|
||||
translation_key="parental_control_blocked",
|
||||
icon="mdi:human-male-girl",
|
||||
native_unit_of_measurement="requests",
|
||||
value_fn=lambda adguard: adguard.stats.replaced_parental(),
|
||||
),
|
||||
AdGuardHomeEntityDescription(
|
||||
key="blocked_safebrowsing",
|
||||
translation_key="safe_browsing_blocked",
|
||||
icon="mdi:shield-half-full",
|
||||
native_unit_of_measurement="requests",
|
||||
value_fn=lambda adguard: adguard.stats.replaced_safebrowsing(),
|
||||
),
|
||||
AdGuardHomeEntityDescription(
|
||||
key="enforced_safesearch",
|
||||
translation_key="safe_searches_enforced",
|
||||
icon="mdi:shield-search",
|
||||
native_unit_of_measurement="requests",
|
||||
value_fn=lambda adguard: adguard.stats.replaced_safesearch(),
|
||||
),
|
||||
AdGuardHomeEntityDescription(
|
||||
key="average_speed",
|
||||
translation_key="average_processing_speed",
|
||||
icon="mdi:speedometer",
|
||||
native_unit_of_measurement=UnitOfTime.MILLISECONDS,
|
||||
value_fn=lambda adguard: adguard.stats.avg_processing_time(),
|
||||
),
|
||||
AdGuardHomeEntityDescription(
|
||||
key="rules_count",
|
||||
translation_key="rules_count",
|
||||
icon="mdi:counter",
|
||||
native_unit_of_measurement="rules",
|
||||
value_fn=lambda adguard: adguard.filtering.rules_count(allowlist=False),
|
||||
entity_registry_enabled_default=False,
|
||||
|
@@ -34,7 +34,6 @@ SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = (
|
||||
AdGuardHomeSwitchEntityDescription(
|
||||
key="protection",
|
||||
translation_key="protection",
|
||||
icon="mdi:shield-check",
|
||||
is_on_fn=lambda adguard: adguard.protection_enabled,
|
||||
turn_on_fn=lambda adguard: adguard.enable_protection,
|
||||
turn_off_fn=lambda adguard: adguard.disable_protection,
|
||||
@@ -42,7 +41,6 @@ SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = (
|
||||
AdGuardHomeSwitchEntityDescription(
|
||||
key="parental",
|
||||
translation_key="parental",
|
||||
icon="mdi:shield-check",
|
||||
is_on_fn=lambda adguard: adguard.parental.enabled,
|
||||
turn_on_fn=lambda adguard: adguard.parental.enable,
|
||||
turn_off_fn=lambda adguard: adguard.parental.disable,
|
||||
@@ -50,7 +48,6 @@ SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = (
|
||||
AdGuardHomeSwitchEntityDescription(
|
||||
key="safesearch",
|
||||
translation_key="safe_search",
|
||||
icon="mdi:shield-check",
|
||||
is_on_fn=lambda adguard: adguard.safesearch.enabled,
|
||||
turn_on_fn=lambda adguard: adguard.safesearch.enable,
|
||||
turn_off_fn=lambda adguard: adguard.safesearch.disable,
|
||||
@@ -58,7 +55,6 @@ SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = (
|
||||
AdGuardHomeSwitchEntityDescription(
|
||||
key="safebrowsing",
|
||||
translation_key="safe_browsing",
|
||||
icon="mdi:shield-check",
|
||||
is_on_fn=lambda adguard: adguard.safebrowsing.enabled,
|
||||
turn_on_fn=lambda adguard: adguard.safebrowsing.enable,
|
||||
turn_off_fn=lambda adguard: adguard.safebrowsing.disable,
|
||||
@@ -66,7 +62,6 @@ SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = (
|
||||
AdGuardHomeSwitchEntityDescription(
|
||||
key="filtering",
|
||||
translation_key="filtering",
|
||||
icon="mdi:shield-check",
|
||||
is_on_fn=lambda adguard: adguard.filtering.enabled,
|
||||
turn_on_fn=lambda adguard: adguard.filtering.enable,
|
||||
turn_off_fn=lambda adguard: adguard.filtering.disable,
|
||||
@@ -74,7 +69,6 @@ SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = (
|
||||
AdGuardHomeSwitchEntityDescription(
|
||||
key="querylog",
|
||||
translation_key="query_log",
|
||||
icon="mdi:shield-check",
|
||||
is_on_fn=lambda adguard: adguard.querylog.enabled,
|
||||
turn_on_fn=lambda adguard: adguard.querylog.enable,
|
||||
turn_off_fn=lambda adguard: adguard.querylog.disable,
|
||||
|
@@ -19,11 +19,11 @@ PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.CLIMATE,
|
||||
Platform.COVER,
|
||||
Platform.LIGHT,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.UPDATE,
|
||||
Platform.LIGHT,
|
||||
]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
@@ -83,12 +83,17 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity):
|
||||
_attr_max_temp = 32
|
||||
_attr_min_temp = 16
|
||||
_attr_name = None
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
|
||||
def __init__(self, instance: AdvantageAirData, ac_key: str) -> None:
|
||||
"""Initialize an AdvantageAir AC unit."""
|
||||
super().__init__(instance, ac_key)
|
||||
|
||||
self._attr_supported_features = ClimateEntityFeature.FAN_MODE
|
||||
self._attr_supported_features = (
|
||||
ClimateEntityFeature.FAN_MODE
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
)
|
||||
self._attr_hvac_modes = [
|
||||
HVACMode.OFF,
|
||||
HVACMode.COOL,
|
||||
@@ -198,11 +203,16 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity):
|
||||
"""AdvantageAir MyTemp Zone control."""
|
||||
|
||||
_attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT_COOL]
|
||||
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
)
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_target_temperature_step = PRECISION_WHOLE
|
||||
_attr_max_temp = 32
|
||||
_attr_min_temp = 16
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
|
||||
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
|
||||
"""Initialize an AdvantageAir Zone control."""
|
||||
|
@@ -40,6 +40,7 @@ async def async_setup_entry(
|
||||
class AdvantageAirLight(AdvantageAirEntity, LightEntity):
|
||||
"""Representation of Advantage Air Light."""
|
||||
|
||||
_attr_color_mode = ColorMode.ONOFF
|
||||
_attr_supported_color_modes = {ColorMode.ONOFF}
|
||||
_attr_name = None
|
||||
|
||||
@@ -82,7 +83,8 @@ class AdvantageAirLight(AdvantageAirEntity, LightEntity):
|
||||
class AdvantageAirLightDimmable(AdvantageAirLight):
|
||||
"""Representation of Advantage Air Dimmable Light."""
|
||||
|
||||
_attr_supported_color_modes = {ColorMode.ONOFF, ColorMode.BRIGHTNESS}
|
||||
_attr_color_mode = ColorMode.BRIGHTNESS
|
||||
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
|
||||
|
||||
def __init__(self, instance: AdvantageAirData, light: dict[str, Any]) -> None:
|
||||
"""Initialize an Advantage Air Dimmable Light."""
|
||||
@@ -106,13 +108,15 @@ class AdvantageAirLightDimmable(AdvantageAirLight):
|
||||
class AdvantageAirThingLight(AdvantageAirThingEntity, LightEntity):
|
||||
"""Representation of Advantage Air Light controlled by myThings."""
|
||||
|
||||
_attr_color_mode = ColorMode.ONOFF
|
||||
_attr_supported_color_modes = {ColorMode.ONOFF}
|
||||
|
||||
|
||||
class AdvantageAirThingLightDimmable(AdvantageAirThingEntity, LightEntity):
|
||||
"""Representation of Advantage Air Dimmable Light controlled by myThings."""
|
||||
|
||||
_attr_supported_color_modes = {ColorMode.ONOFF, ColorMode.BRIGHTNESS}
|
||||
_attr_color_mode = ColorMode.BRIGHTNESS
|
||||
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
|
||||
|
||||
@property
|
||||
def brightness(self) -> int:
|
||||
|
@@ -18,7 +18,7 @@ from .const import (
|
||||
ENTRY_WEATHER_COORDINATOR,
|
||||
PLATFORMS,
|
||||
)
|
||||
from .weather_update_coordinator import WeatherUpdateCoordinator
|
||||
from .coordinator import WeatherUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@@ -59,8 +59,6 @@ ENTRY_WEATHER_COORDINATOR = "weather_coordinator"
|
||||
|
||||
ATTR_API_CONDITION = "condition"
|
||||
ATTR_API_FORECAST_CONDITION = "condition"
|
||||
ATTR_API_FORECAST_DAILY = "forecast-daily"
|
||||
ATTR_API_FORECAST_HOURLY = "forecast-hourly"
|
||||
ATTR_API_FORECAST_PRECIPITATION = "precipitation"
|
||||
ATTR_API_FORECAST_PRECIPITATION_PROBABILITY = "precipitation_probability"
|
||||
ATTR_API_FORECAST_TEMP = "temperature"
|
||||
@@ -101,49 +99,6 @@ CONDITIONS_MAP = {
|
||||
AOD_COND_SUNNY: ATTR_CONDITION_SUNNY,
|
||||
}
|
||||
|
||||
FORECAST_MONITORED_CONDITIONS = [
|
||||
ATTR_API_FORECAST_CONDITION,
|
||||
ATTR_API_FORECAST_PRECIPITATION,
|
||||
ATTR_API_FORECAST_PRECIPITATION_PROBABILITY,
|
||||
ATTR_API_FORECAST_TEMP,
|
||||
ATTR_API_FORECAST_TEMP_LOW,
|
||||
ATTR_API_FORECAST_TIME,
|
||||
ATTR_API_FORECAST_WIND_BEARING,
|
||||
ATTR_API_FORECAST_WIND_SPEED,
|
||||
]
|
||||
MONITORED_CONDITIONS = [
|
||||
ATTR_API_CONDITION,
|
||||
ATTR_API_HUMIDITY,
|
||||
ATTR_API_PRESSURE,
|
||||
ATTR_API_RAIN,
|
||||
ATTR_API_RAIN_PROB,
|
||||
ATTR_API_SNOW,
|
||||
ATTR_API_SNOW_PROB,
|
||||
ATTR_API_STATION_ID,
|
||||
ATTR_API_STATION_NAME,
|
||||
ATTR_API_STATION_TIMESTAMP,
|
||||
ATTR_API_STORM_PROB,
|
||||
ATTR_API_TEMPERATURE,
|
||||
ATTR_API_TEMPERATURE_FEELING,
|
||||
ATTR_API_TOWN_ID,
|
||||
ATTR_API_TOWN_NAME,
|
||||
ATTR_API_TOWN_TIMESTAMP,
|
||||
ATTR_API_WIND_BEARING,
|
||||
ATTR_API_WIND_MAX_SPEED,
|
||||
ATTR_API_WIND_SPEED,
|
||||
]
|
||||
|
||||
FORECAST_MODE_DAILY = "daily"
|
||||
FORECAST_MODE_HOURLY = "hourly"
|
||||
FORECAST_MODES = [
|
||||
FORECAST_MODE_DAILY,
|
||||
FORECAST_MODE_HOURLY,
|
||||
]
|
||||
FORECAST_MODE_ATTR_API = {
|
||||
FORECAST_MODE_DAILY: ATTR_API_FORECAST_DAILY,
|
||||
FORECAST_MODE_HOURLY: ATTR_API_FORECAST_HOURLY,
|
||||
}
|
||||
|
||||
FORECAST_MAP = {
|
||||
AOD_FORECAST_DAILY: {
|
||||
AOD_CONDITION: ATTR_FORECAST_CONDITION,
|
||||
|
85
homeassistant/components/aemet/coordinator.py
Normal file
85
homeassistant/components/aemet/coordinator.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Weather data coordinator for the AEMET OpenData service."""
|
||||
from __future__ import annotations
|
||||
|
||||
from asyncio import timeout
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any, Final, cast
|
||||
|
||||
from aemet_opendata.const import (
|
||||
AOD_CONDITION,
|
||||
AOD_FORECAST,
|
||||
AOD_FORECAST_DAILY,
|
||||
AOD_FORECAST_HOURLY,
|
||||
AOD_TOWN,
|
||||
)
|
||||
from aemet_opendata.exceptions import AemetError
|
||||
from aemet_opendata.helpers import dict_nested_value
|
||||
from aemet_opendata.interface import AEMET
|
||||
|
||||
from homeassistant.components.weather import Forecast
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import CONDITIONS_MAP, DOMAIN, FORECAST_MAP
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
API_TIMEOUT: Final[int] = 120
|
||||
WEATHER_UPDATE_INTERVAL = timedelta(minutes=10)
|
||||
|
||||
|
||||
class WeatherUpdateCoordinator(DataUpdateCoordinator):
|
||||
"""Weather data update coordinator."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
aemet: AEMET,
|
||||
) -> None:
|
||||
"""Initialize coordinator."""
|
||||
self.aemet = aemet
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=WEATHER_UPDATE_INTERVAL,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Update coordinator data."""
|
||||
async with timeout(API_TIMEOUT):
|
||||
try:
|
||||
await self.aemet.update()
|
||||
except AemetError as error:
|
||||
raise UpdateFailed(error) from error
|
||||
|
||||
data = self.aemet.data()
|
||||
|
||||
return {
|
||||
"forecast": {
|
||||
AOD_FORECAST_DAILY: self.aemet_forecast(data, AOD_FORECAST_DAILY),
|
||||
AOD_FORECAST_HOURLY: self.aemet_forecast(data, AOD_FORECAST_HOURLY),
|
||||
},
|
||||
"lib": data,
|
||||
}
|
||||
|
||||
def aemet_forecast(
|
||||
self,
|
||||
data: dict[str, Any],
|
||||
forecast_mode: str,
|
||||
) -> list[Forecast]:
|
||||
"""Return the forecast array."""
|
||||
forecasts = dict_nested_value(data, [AOD_TOWN, forecast_mode, AOD_FORECAST])
|
||||
forecast_map = FORECAST_MAP[forecast_mode]
|
||||
forecast_list: list[dict[str, Any]] = []
|
||||
for forecast in forecasts:
|
||||
cur_forecast: dict[str, Any] = {}
|
||||
for api_key, ha_key in forecast_map.items():
|
||||
value = forecast[api_key]
|
||||
if api_key == AOD_CONDITION:
|
||||
value = CONDITIONS_MAP.get(value)
|
||||
cur_forecast[ha_key] = value
|
||||
forecast_list += [cur_forecast]
|
||||
return cast(list[Forecast], forecast_list)
|
@@ -8,7 +8,7 @@ from aemet_opendata.helpers import dict_nested_value
|
||||
from homeassistant.components.weather import Forecast
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .weather_update_coordinator import WeatherUpdateCoordinator
|
||||
from .coordinator import WeatherUpdateCoordinator
|
||||
|
||||
|
||||
class AemetEntity(CoordinatorEntity[WeatherUpdateCoordinator]):
|
||||
|
@@ -1,6 +1,41 @@
|
||||
"""Support for the AEMET OpenData service."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Final
|
||||
|
||||
from aemet_opendata.const import (
|
||||
AOD_CONDITION,
|
||||
AOD_FEEL_TEMP,
|
||||
AOD_FORECAST_CURRENT,
|
||||
AOD_FORECAST_DAILY,
|
||||
AOD_FORECAST_HOURLY,
|
||||
AOD_HUMIDITY,
|
||||
AOD_ID,
|
||||
AOD_NAME,
|
||||
AOD_PRECIPITATION,
|
||||
AOD_PRECIPITATION_PROBABILITY,
|
||||
AOD_PRESSURE,
|
||||
AOD_RAIN,
|
||||
AOD_RAIN_PROBABILITY,
|
||||
AOD_SNOW,
|
||||
AOD_SNOW_PROBABILITY,
|
||||
AOD_STATION,
|
||||
AOD_STORM_PROBABILITY,
|
||||
AOD_TEMP,
|
||||
AOD_TEMP_MAX,
|
||||
AOD_TEMP_MIN,
|
||||
AOD_TIMESTAMP,
|
||||
AOD_TOWN,
|
||||
AOD_WEATHER,
|
||||
AOD_WIND_DIRECTION,
|
||||
AOD_WIND_SPEED,
|
||||
AOD_WIND_SPEED_MAX,
|
||||
)
|
||||
from aemet_opendata.helpers import dict_nested_value
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
@@ -18,7 +53,6 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
@@ -51,172 +85,270 @@ from .const import (
|
||||
ATTR_API_WIND_MAX_SPEED,
|
||||
ATTR_API_WIND_SPEED,
|
||||
ATTRIBUTION,
|
||||
CONDITIONS_MAP,
|
||||
DOMAIN,
|
||||
ENTRY_NAME,
|
||||
ENTRY_WEATHER_COORDINATOR,
|
||||
FORECAST_MODE_ATTR_API,
|
||||
FORECAST_MODE_DAILY,
|
||||
FORECAST_MODES,
|
||||
FORECAST_MONITORED_CONDITIONS,
|
||||
MONITORED_CONDITIONS,
|
||||
)
|
||||
from .weather_update_coordinator import WeatherUpdateCoordinator
|
||||
from .coordinator import WeatherUpdateCoordinator
|
||||
from .entity import AemetEntity
|
||||
|
||||
FORECAST_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
key=ATTR_API_FORECAST_CONDITION,
|
||||
name="Condition",
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AemetSensorEntityDescription(SensorEntityDescription):
|
||||
"""A class that describes AEMET OpenData sensor entities."""
|
||||
|
||||
keys: list[str] | None = None
|
||||
value_fn: Callable[[str], datetime | float | int | str | None] = lambda value: value
|
||||
|
||||
|
||||
FORECAST_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
|
||||
AemetSensorEntityDescription(
|
||||
key=f"forecast-daily-{ATTR_API_FORECAST_CONDITION}",
|
||||
keys=[AOD_TOWN, AOD_FORECAST_DAILY, AOD_FORECAST_CURRENT, AOD_CONDITION],
|
||||
name="Daily forecast condition",
|
||||
value_fn=CONDITIONS_MAP.get,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=ATTR_API_FORECAST_PRECIPITATION,
|
||||
name="Precipitation",
|
||||
AemetSensorEntityDescription(
|
||||
entity_registry_enabled_default=False,
|
||||
key=f"forecast-hourly-{ATTR_API_FORECAST_CONDITION}",
|
||||
keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_CONDITION],
|
||||
name="Hourly forecast condition",
|
||||
value_fn=CONDITIONS_MAP.get,
|
||||
),
|
||||
AemetSensorEntityDescription(
|
||||
entity_registry_enabled_default=False,
|
||||
key=f"forecast-hourly-{ATTR_API_FORECAST_PRECIPITATION}",
|
||||
keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_PRECIPITATION],
|
||||
name="Hourly forecast precipitation",
|
||||
native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR,
|
||||
device_class=SensorDeviceClass.PRECIPITATION_INTENSITY,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=ATTR_API_FORECAST_PRECIPITATION_PROBABILITY,
|
||||
name="Precipitation probability",
|
||||
AemetSensorEntityDescription(
|
||||
key=f"forecast-daily-{ATTR_API_FORECAST_PRECIPITATION_PROBABILITY}",
|
||||
keys=[
|
||||
AOD_TOWN,
|
||||
AOD_FORECAST_DAILY,
|
||||
AOD_FORECAST_CURRENT,
|
||||
AOD_PRECIPITATION_PROBABILITY,
|
||||
],
|
||||
name="Daily forecast precipitation probability",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=ATTR_API_FORECAST_TEMP,
|
||||
name="Temperature",
|
||||
AemetSensorEntityDescription(
|
||||
entity_registry_enabled_default=False,
|
||||
key=f"forecast-hourly-{ATTR_API_FORECAST_PRECIPITATION_PROBABILITY}",
|
||||
keys=[
|
||||
AOD_TOWN,
|
||||
AOD_FORECAST_HOURLY,
|
||||
AOD_FORECAST_CURRENT,
|
||||
AOD_PRECIPITATION_PROBABILITY,
|
||||
],
|
||||
name="Hourly forecast precipitation probability",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
),
|
||||
AemetSensorEntityDescription(
|
||||
key=f"forecast-daily-{ATTR_API_FORECAST_TEMP}",
|
||||
keys=[AOD_TOWN, AOD_FORECAST_DAILY, AOD_FORECAST_CURRENT, AOD_TEMP_MAX],
|
||||
name="Daily forecast temperature",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=ATTR_API_FORECAST_TEMP_LOW,
|
||||
name="Temperature Low",
|
||||
AemetSensorEntityDescription(
|
||||
key=f"forecast-daily-{ATTR_API_FORECAST_TEMP_LOW}",
|
||||
keys=[AOD_TOWN, AOD_FORECAST_DAILY, AOD_FORECAST_CURRENT, AOD_TEMP_MIN],
|
||||
name="Daily forecast temperature low",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=ATTR_API_FORECAST_TIME,
|
||||
name="Time",
|
||||
AemetSensorEntityDescription(
|
||||
entity_registry_enabled_default=False,
|
||||
key=f"forecast-hourly-{ATTR_API_FORECAST_TEMP}",
|
||||
keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_TEMP],
|
||||
name="Hourly forecast temperature",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
AemetSensorEntityDescription(
|
||||
key=f"forecast-daily-{ATTR_API_FORECAST_TIME}",
|
||||
keys=[AOD_TOWN, AOD_FORECAST_DAILY, AOD_FORECAST_CURRENT, AOD_TIMESTAMP],
|
||||
name="Daily forecast time",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=dt_util.parse_datetime,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=ATTR_API_FORECAST_WIND_BEARING,
|
||||
name="Wind bearing",
|
||||
AemetSensorEntityDescription(
|
||||
entity_registry_enabled_default=False,
|
||||
key=f"forecast-hourly-{ATTR_API_FORECAST_TIME}",
|
||||
keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_TIMESTAMP],
|
||||
name="Hourly forecast time",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=dt_util.parse_datetime,
|
||||
),
|
||||
AemetSensorEntityDescription(
|
||||
key=f"forecast-daily-{ATTR_API_FORECAST_WIND_BEARING}",
|
||||
keys=[AOD_TOWN, AOD_FORECAST_DAILY, AOD_FORECAST_CURRENT, AOD_WIND_DIRECTION],
|
||||
name="Daily forecast wind bearing",
|
||||
native_unit_of_measurement=DEGREE,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=ATTR_API_FORECAST_WIND_MAX_SPEED,
|
||||
name="Wind max speed",
|
||||
AemetSensorEntityDescription(
|
||||
entity_registry_enabled_default=False,
|
||||
key=f"forecast-hourly-{ATTR_API_FORECAST_WIND_BEARING}",
|
||||
keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_WIND_DIRECTION],
|
||||
name="Hourly forecast wind bearing",
|
||||
native_unit_of_measurement=DEGREE,
|
||||
),
|
||||
AemetSensorEntityDescription(
|
||||
entity_registry_enabled_default=False,
|
||||
key=f"forecast-hourly-{ATTR_API_FORECAST_WIND_MAX_SPEED}",
|
||||
keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_WIND_SPEED_MAX],
|
||||
name="Hourly forecast wind max speed",
|
||||
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=ATTR_API_FORECAST_WIND_SPEED,
|
||||
name="Wind speed",
|
||||
AemetSensorEntityDescription(
|
||||
key=f"forecast-daily-{ATTR_API_FORECAST_WIND_SPEED}",
|
||||
keys=[AOD_TOWN, AOD_FORECAST_DAILY, AOD_FORECAST_CURRENT, AOD_WIND_SPEED],
|
||||
name="Daily forecast wind speed",
|
||||
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
),
|
||||
AemetSensorEntityDescription(
|
||||
entity_registry_enabled_default=False,
|
||||
key=f"forecast-hourly-{ATTR_API_FORECAST_WIND_SPEED}",
|
||||
keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_WIND_SPEED],
|
||||
name="Hourly forecast wind speed",
|
||||
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
),
|
||||
)
|
||||
WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
|
||||
|
||||
WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
|
||||
AemetSensorEntityDescription(
|
||||
key=ATTR_API_CONDITION,
|
||||
keys=[AOD_WEATHER, AOD_CONDITION],
|
||||
name="Condition",
|
||||
value_fn=CONDITIONS_MAP.get,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
AemetSensorEntityDescription(
|
||||
key=ATTR_API_HUMIDITY,
|
||||
keys=[AOD_WEATHER, AOD_HUMIDITY],
|
||||
name="Humidity",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
AemetSensorEntityDescription(
|
||||
key=ATTR_API_PRESSURE,
|
||||
keys=[AOD_WEATHER, AOD_PRESSURE],
|
||||
name="Pressure",
|
||||
native_unit_of_measurement=UnitOfPressure.HPA,
|
||||
device_class=SensorDeviceClass.PRESSURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
AemetSensorEntityDescription(
|
||||
key=ATTR_API_RAIN,
|
||||
keys=[AOD_WEATHER, AOD_RAIN],
|
||||
name="Rain",
|
||||
native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR,
|
||||
device_class=SensorDeviceClass.PRECIPITATION_INTENSITY,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
AemetSensorEntityDescription(
|
||||
key=ATTR_API_RAIN_PROB,
|
||||
keys=[AOD_WEATHER, AOD_RAIN_PROBABILITY],
|
||||
name="Rain probability",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
AemetSensorEntityDescription(
|
||||
key=ATTR_API_SNOW,
|
||||
keys=[AOD_WEATHER, AOD_SNOW],
|
||||
name="Snow",
|
||||
native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR,
|
||||
device_class=SensorDeviceClass.PRECIPITATION_INTENSITY,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
AemetSensorEntityDescription(
|
||||
key=ATTR_API_SNOW_PROB,
|
||||
keys=[AOD_WEATHER, AOD_SNOW_PROBABILITY],
|
||||
name="Snow probability",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
AemetSensorEntityDescription(
|
||||
key=ATTR_API_STATION_ID,
|
||||
keys=[AOD_STATION, AOD_ID],
|
||||
name="Station ID",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
AemetSensorEntityDescription(
|
||||
key=ATTR_API_STATION_NAME,
|
||||
keys=[AOD_STATION, AOD_NAME],
|
||||
name="Station name",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
AemetSensorEntityDescription(
|
||||
key=ATTR_API_STATION_TIMESTAMP,
|
||||
keys=[AOD_STATION, AOD_TIMESTAMP],
|
||||
name="Station timestamp",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=dt_util.parse_datetime,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
AemetSensorEntityDescription(
|
||||
key=ATTR_API_STORM_PROB,
|
||||
keys=[AOD_WEATHER, AOD_STORM_PROBABILITY],
|
||||
name="Storm probability",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
AemetSensorEntityDescription(
|
||||
key=ATTR_API_TEMPERATURE,
|
||||
keys=[AOD_WEATHER, AOD_TEMP],
|
||||
name="Temperature",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
AemetSensorEntityDescription(
|
||||
key=ATTR_API_TEMPERATURE_FEELING,
|
||||
keys=[AOD_WEATHER, AOD_FEEL_TEMP],
|
||||
name="Temperature feeling",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
AemetSensorEntityDescription(
|
||||
key=ATTR_API_TOWN_ID,
|
||||
keys=[AOD_TOWN, AOD_ID],
|
||||
name="Town ID",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
AemetSensorEntityDescription(
|
||||
key=ATTR_API_TOWN_NAME,
|
||||
keys=[AOD_TOWN, AOD_NAME],
|
||||
name="Town name",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
AemetSensorEntityDescription(
|
||||
key=ATTR_API_TOWN_TIMESTAMP,
|
||||
keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_TIMESTAMP],
|
||||
name="Town timestamp",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=dt_util.parse_datetime,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
AemetSensorEntityDescription(
|
||||
key=ATTR_API_WIND_BEARING,
|
||||
keys=[AOD_WEATHER, AOD_WIND_DIRECTION],
|
||||
name="Wind bearing",
|
||||
native_unit_of_measurement=DEGREE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
AemetSensorEntityDescription(
|
||||
key=ATTR_API_WIND_MAX_SPEED,
|
||||
keys=[AOD_WEATHER, AOD_WIND_SPEED_MAX],
|
||||
name="Wind max speed",
|
||||
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
AemetSensorEntityDescription(
|
||||
key=ATTR_API_WIND_SPEED,
|
||||
keys=[AOD_WEATHER, AOD_WIND_SPEED],
|
||||
name="Wind speed",
|
||||
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
@@ -232,108 +364,46 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up AEMET OpenData sensor entities based on a config entry."""
|
||||
domain_data = hass.data[DOMAIN][config_entry.entry_id]
|
||||
name = domain_data[ENTRY_NAME]
|
||||
weather_coordinator = domain_data[ENTRY_WEATHER_COORDINATOR]
|
||||
name: str = domain_data[ENTRY_NAME]
|
||||
coordinator: WeatherUpdateCoordinator = domain_data[ENTRY_WEATHER_COORDINATOR]
|
||||
|
||||
unique_id = config_entry.unique_id
|
||||
entities: list[AbstractAemetSensor] = [
|
||||
AemetSensor(name, unique_id, weather_coordinator, description)
|
||||
for description in WEATHER_SENSOR_TYPES
|
||||
if description.key in MONITORED_CONDITIONS
|
||||
]
|
||||
entities.extend(
|
||||
[
|
||||
AemetForecastSensor(
|
||||
f"{domain_data[ENTRY_NAME]} {mode} Forecast",
|
||||
f"{unique_id}-forecast-{mode}",
|
||||
weather_coordinator,
|
||||
mode,
|
||||
description,
|
||||
entities: list[AemetSensor] = []
|
||||
|
||||
for description in FORECAST_SENSORS + WEATHER_SENSORS:
|
||||
if dict_nested_value(coordinator.data["lib"], description.keys) is not None:
|
||||
entities.append(
|
||||
AemetSensor(
|
||||
name,
|
||||
coordinator,
|
||||
description,
|
||||
config_entry,
|
||||
)
|
||||
)
|
||||
for mode in FORECAST_MODES
|
||||
for description in FORECAST_SENSOR_TYPES
|
||||
if description.key in FORECAST_MONITORED_CONDITIONS
|
||||
]
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class AbstractAemetSensor(CoordinatorEntity[WeatherUpdateCoordinator], SensorEntity):
|
||||
"""Abstract class for an AEMET OpenData sensor."""
|
||||
class AemetSensor(AemetEntity, SensorEntity):
|
||||
"""Implementation of an AEMET OpenData sensor."""
|
||||
|
||||
_attr_attribution = ATTRIBUTION
|
||||
entity_description: AemetSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name,
|
||||
unique_id,
|
||||
name: str,
|
||||
coordinator: WeatherUpdateCoordinator,
|
||||
description: SensorEntityDescription,
|
||||
description: AemetSensorEntityDescription,
|
||||
config_entry: ConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_name = f"{name} {description.name}"
|
||||
self._attr_unique_id = unique_id
|
||||
|
||||
|
||||
class AemetSensor(AbstractAemetSensor):
|
||||
"""Implementation of an AEMET OpenData sensor."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name,
|
||||
unique_id_prefix,
|
||||
weather_coordinator: WeatherUpdateCoordinator,
|
||||
description: SensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(
|
||||
name=name,
|
||||
unique_id=f"{unique_id_prefix}-{description.key}",
|
||||
coordinator=weather_coordinator,
|
||||
description=description,
|
||||
)
|
||||
self._attr_unique_id = f"{config_entry.unique_id}-{description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
"""Return the state of the device."""
|
||||
return self.coordinator.data.get(self.entity_description.key)
|
||||
|
||||
|
||||
class AemetForecastSensor(AbstractAemetSensor):
|
||||
"""Implementation of an AEMET OpenData forecast sensor."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name,
|
||||
unique_id_prefix,
|
||||
weather_coordinator: WeatherUpdateCoordinator,
|
||||
forecast_mode,
|
||||
description: SensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(
|
||||
name=name,
|
||||
unique_id=f"{unique_id_prefix}-{description.key}",
|
||||
coordinator=weather_coordinator,
|
||||
description=description,
|
||||
)
|
||||
self._forecast_mode = forecast_mode
|
||||
self._attr_entity_registry_enabled_default = (
|
||||
self._forecast_mode == FORECAST_MODE_DAILY
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
"""Return the state of the device."""
|
||||
forecast = None
|
||||
forecasts = self.coordinator.data.get(
|
||||
FORECAST_MODE_ATTR_API[self._forecast_mode]
|
||||
)
|
||||
if forecasts:
|
||||
forecast = forecasts[0].get(self.entity_description.key)
|
||||
if self.entity_description.key == ATTR_API_FORECAST_TIME:
|
||||
forecast = dt_util.parse_datetime(forecast)
|
||||
return forecast
|
||||
value = self.get_aemet_value(self.entity_description.keys)
|
||||
return self.entity_description.value_fn(value)
|
||||
|
@@ -38,8 +38,8 @@ from .const import (
|
||||
ENTRY_WEATHER_COORDINATOR,
|
||||
WEATHER_FORECAST_MODES,
|
||||
)
|
||||
from .coordinator import WeatherUpdateCoordinator
|
||||
from .entity import AemetEntity
|
||||
from .weather_update_coordinator import WeatherUpdateCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
|
@@ -1,558 +0,0 @@
|
||||
"""Weather data coordinator for the AEMET OpenData service."""
|
||||
from __future__ import annotations
|
||||
|
||||
from asyncio import timeout
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any, Final, cast
|
||||
|
||||
from aemet_opendata.const import (
|
||||
AEMET_ATTR_DATE,
|
||||
AEMET_ATTR_DAY,
|
||||
AEMET_ATTR_DIRECTION,
|
||||
AEMET_ATTR_ELABORATED,
|
||||
AEMET_ATTR_FEEL_TEMPERATURE,
|
||||
AEMET_ATTR_FORECAST,
|
||||
AEMET_ATTR_HUMIDITY,
|
||||
AEMET_ATTR_MAX,
|
||||
AEMET_ATTR_MIN,
|
||||
AEMET_ATTR_PRECIPITATION,
|
||||
AEMET_ATTR_PRECIPITATION_PROBABILITY,
|
||||
AEMET_ATTR_SKY_STATE,
|
||||
AEMET_ATTR_SNOW,
|
||||
AEMET_ATTR_SNOW_PROBABILITY,
|
||||
AEMET_ATTR_SPEED,
|
||||
AEMET_ATTR_STATION_DATE,
|
||||
AEMET_ATTR_STATION_HUMIDITY,
|
||||
AEMET_ATTR_STATION_PRESSURE,
|
||||
AEMET_ATTR_STATION_PRESSURE_SEA,
|
||||
AEMET_ATTR_STATION_TEMPERATURE,
|
||||
AEMET_ATTR_STORM_PROBABILITY,
|
||||
AEMET_ATTR_TEMPERATURE,
|
||||
AEMET_ATTR_WIND,
|
||||
AEMET_ATTR_WIND_GUST,
|
||||
AOD_CONDITION,
|
||||
AOD_FORECAST,
|
||||
AOD_FORECAST_DAILY,
|
||||
AOD_FORECAST_HOURLY,
|
||||
AOD_TOWN,
|
||||
ATTR_DATA,
|
||||
)
|
||||
from aemet_opendata.exceptions import AemetError
|
||||
from aemet_opendata.forecast import ForecastValue
|
||||
from aemet_opendata.helpers import (
|
||||
dict_nested_value,
|
||||
get_forecast_day_value,
|
||||
get_forecast_hour_value,
|
||||
get_forecast_interval_value,
|
||||
)
|
||||
from aemet_opendata.interface import AEMET
|
||||
|
||||
from homeassistant.components.weather import Forecast
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
ATTR_API_CONDITION,
|
||||
ATTR_API_FORECAST_CONDITION,
|
||||
ATTR_API_FORECAST_DAILY,
|
||||
ATTR_API_FORECAST_HOURLY,
|
||||
ATTR_API_FORECAST_PRECIPITATION,
|
||||
ATTR_API_FORECAST_PRECIPITATION_PROBABILITY,
|
||||
ATTR_API_FORECAST_TEMP,
|
||||
ATTR_API_FORECAST_TEMP_LOW,
|
||||
ATTR_API_FORECAST_TIME,
|
||||
ATTR_API_FORECAST_WIND_BEARING,
|
||||
ATTR_API_FORECAST_WIND_MAX_SPEED,
|
||||
ATTR_API_FORECAST_WIND_SPEED,
|
||||
ATTR_API_HUMIDITY,
|
||||
ATTR_API_PRESSURE,
|
||||
ATTR_API_RAIN,
|
||||
ATTR_API_RAIN_PROB,
|
||||
ATTR_API_SNOW,
|
||||
ATTR_API_SNOW_PROB,
|
||||
ATTR_API_STATION_ID,
|
||||
ATTR_API_STATION_NAME,
|
||||
ATTR_API_STATION_TIMESTAMP,
|
||||
ATTR_API_STORM_PROB,
|
||||
ATTR_API_TEMPERATURE,
|
||||
ATTR_API_TEMPERATURE_FEELING,
|
||||
ATTR_API_TOWN_ID,
|
||||
ATTR_API_TOWN_NAME,
|
||||
ATTR_API_TOWN_TIMESTAMP,
|
||||
ATTR_API_WIND_BEARING,
|
||||
ATTR_API_WIND_MAX_SPEED,
|
||||
ATTR_API_WIND_SPEED,
|
||||
CONDITIONS_MAP,
|
||||
DOMAIN,
|
||||
FORECAST_MAP,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
API_TIMEOUT: Final[int] = 120
|
||||
STATION_MAX_DELTA = timedelta(hours=2)
|
||||
WEATHER_UPDATE_INTERVAL = timedelta(minutes=10)
|
||||
|
||||
|
||||
def format_condition(condition: str) -> str:
|
||||
"""Return condition from dict CONDITIONS_MAP."""
|
||||
val = ForecastValue.parse_condition(condition)
|
||||
return CONDITIONS_MAP.get(val, val)
|
||||
|
||||
|
||||
def format_float(value) -> float | None:
|
||||
"""Try converting string to float."""
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def format_int(value) -> int | None:
|
||||
"""Try converting string to int."""
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
class WeatherUpdateCoordinator(DataUpdateCoordinator):
|
||||
"""Weather data update coordinator."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
aemet: AEMET,
|
||||
) -> None:
|
||||
"""Initialize coordinator."""
|
||||
self.aemet = aemet
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=WEATHER_UPDATE_INTERVAL,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Update coordinator data."""
|
||||
async with timeout(API_TIMEOUT):
|
||||
try:
|
||||
await self.aemet.update()
|
||||
except AemetError as error:
|
||||
raise UpdateFailed(error) from error
|
||||
weather_response = self.aemet.legacy_weather()
|
||||
return self._convert_weather_response(weather_response)
|
||||
|
||||
def _convert_weather_response(self, weather_response):
|
||||
"""Format the weather response correctly."""
|
||||
if not weather_response or not weather_response.hourly:
|
||||
return None
|
||||
|
||||
elaborated = dt_util.parse_datetime(
|
||||
weather_response.hourly[ATTR_DATA][0][AEMET_ATTR_ELABORATED] + "Z"
|
||||
)
|
||||
now = dt_util.now()
|
||||
now_utc = dt_util.utcnow()
|
||||
hour = now.hour
|
||||
|
||||
# Get current day
|
||||
day = None
|
||||
for cur_day in weather_response.hourly[ATTR_DATA][0][AEMET_ATTR_FORECAST][
|
||||
AEMET_ATTR_DAY
|
||||
]:
|
||||
cur_day_date = dt_util.parse_datetime(cur_day[AEMET_ATTR_DATE])
|
||||
if now.date() == cur_day_date.date():
|
||||
day = cur_day
|
||||
break
|
||||
|
||||
# Get latest station data
|
||||
station_data = None
|
||||
station_dt = None
|
||||
if weather_response.station:
|
||||
for _station_data in weather_response.station[ATTR_DATA]:
|
||||
if AEMET_ATTR_STATION_DATE in _station_data:
|
||||
_station_dt = dt_util.parse_datetime(
|
||||
_station_data[AEMET_ATTR_STATION_DATE] + "Z"
|
||||
)
|
||||
if not station_dt or _station_dt > station_dt:
|
||||
station_data = _station_data
|
||||
station_dt = _station_dt
|
||||
|
||||
condition = None
|
||||
humidity = None
|
||||
pressure = None
|
||||
rain = None
|
||||
rain_prob = None
|
||||
snow = None
|
||||
snow_prob = None
|
||||
station_id = None
|
||||
station_name = None
|
||||
station_timestamp = None
|
||||
storm_prob = None
|
||||
temperature = None
|
||||
temperature_feeling = None
|
||||
town_id = None
|
||||
town_name = None
|
||||
town_timestamp = dt_util.as_utc(elaborated)
|
||||
wind_bearing = None
|
||||
wind_max_speed = None
|
||||
wind_speed = None
|
||||
|
||||
# Get weather values
|
||||
if day:
|
||||
condition = self._get_condition(day, hour)
|
||||
humidity = self._get_humidity(day, hour)
|
||||
rain = self._get_rain(day, hour)
|
||||
rain_prob = self._get_rain_prob(day, hour)
|
||||
snow = self._get_snow(day, hour)
|
||||
snow_prob = self._get_snow_prob(day, hour)
|
||||
station_id = self._get_station_id()
|
||||
station_name = self._get_station_name()
|
||||
storm_prob = self._get_storm_prob(day, hour)
|
||||
temperature = self._get_temperature(day, hour)
|
||||
temperature_feeling = self._get_temperature_feeling(day, hour)
|
||||
town_id = self._get_town_id()
|
||||
town_name = self._get_town_name()
|
||||
wind_bearing = self._get_wind_bearing(day, hour)
|
||||
wind_max_speed = self._get_wind_max_speed(day, hour)
|
||||
wind_speed = self._get_wind_speed(day, hour)
|
||||
|
||||
# Overwrite weather values with closest station data (if present)
|
||||
if station_data:
|
||||
station_timestamp = dt_util.as_utc(station_dt)
|
||||
if (now_utc - station_dt) <= STATION_MAX_DELTA:
|
||||
if AEMET_ATTR_STATION_HUMIDITY in station_data:
|
||||
humidity = format_float(station_data[AEMET_ATTR_STATION_HUMIDITY])
|
||||
if AEMET_ATTR_STATION_PRESSURE_SEA in station_data:
|
||||
pressure = format_float(
|
||||
station_data[AEMET_ATTR_STATION_PRESSURE_SEA]
|
||||
)
|
||||
elif AEMET_ATTR_STATION_PRESSURE in station_data:
|
||||
pressure = format_float(station_data[AEMET_ATTR_STATION_PRESSURE])
|
||||
if AEMET_ATTR_STATION_TEMPERATURE in station_data:
|
||||
temperature = format_float(
|
||||
station_data[AEMET_ATTR_STATION_TEMPERATURE]
|
||||
)
|
||||
else:
|
||||
_LOGGER.warning("Station data is outdated")
|
||||
|
||||
# Get forecast from weather data
|
||||
forecast_daily = self._get_daily_forecast_from_weather_response(
|
||||
weather_response, now
|
||||
)
|
||||
forecast_hourly = self._get_hourly_forecast_from_weather_response(
|
||||
weather_response, now
|
||||
)
|
||||
|
||||
data = self.aemet.data()
|
||||
forecasts: list[dict[str, Forecast]] = {
|
||||
AOD_FORECAST_DAILY: self.aemet_forecast(data, AOD_FORECAST_DAILY),
|
||||
AOD_FORECAST_HOURLY: self.aemet_forecast(data, AOD_FORECAST_HOURLY),
|
||||
}
|
||||
|
||||
return {
|
||||
ATTR_API_CONDITION: condition,
|
||||
ATTR_API_FORECAST_DAILY: forecast_daily,
|
||||
ATTR_API_FORECAST_HOURLY: forecast_hourly,
|
||||
ATTR_API_HUMIDITY: humidity,
|
||||
ATTR_API_TEMPERATURE: temperature,
|
||||
ATTR_API_TEMPERATURE_FEELING: temperature_feeling,
|
||||
ATTR_API_PRESSURE: pressure,
|
||||
ATTR_API_RAIN: rain,
|
||||
ATTR_API_RAIN_PROB: rain_prob,
|
||||
ATTR_API_SNOW: snow,
|
||||
ATTR_API_SNOW_PROB: snow_prob,
|
||||
ATTR_API_STATION_ID: station_id,
|
||||
ATTR_API_STATION_NAME: station_name,
|
||||
ATTR_API_STATION_TIMESTAMP: station_timestamp,
|
||||
ATTR_API_STORM_PROB: storm_prob,
|
||||
ATTR_API_TOWN_ID: town_id,
|
||||
ATTR_API_TOWN_NAME: town_name,
|
||||
ATTR_API_TOWN_TIMESTAMP: town_timestamp,
|
||||
ATTR_API_WIND_BEARING: wind_bearing,
|
||||
ATTR_API_WIND_MAX_SPEED: wind_max_speed,
|
||||
ATTR_API_WIND_SPEED: wind_speed,
|
||||
"forecast": forecasts,
|
||||
"lib": data,
|
||||
}
|
||||
|
||||
def aemet_forecast(
|
||||
self,
|
||||
data: dict[str, Any],
|
||||
forecast_mode: str,
|
||||
) -> list[Forecast]:
|
||||
"""Return the forecast array."""
|
||||
forecasts = dict_nested_value(data, [AOD_TOWN, forecast_mode, AOD_FORECAST])
|
||||
forecast_map = FORECAST_MAP[forecast_mode]
|
||||
forecast_list: list[dict[str, Any]] = []
|
||||
for forecast in forecasts:
|
||||
cur_forecast: dict[str, Any] = {}
|
||||
for api_key, ha_key in forecast_map.items():
|
||||
value = forecast[api_key]
|
||||
if api_key == AOD_CONDITION:
|
||||
value = CONDITIONS_MAP.get(value)
|
||||
cur_forecast[ha_key] = value
|
||||
forecast_list += [cur_forecast]
|
||||
return cast(list[Forecast], forecast_list)
|
||||
|
||||
def _get_daily_forecast_from_weather_response(self, weather_response, now):
|
||||
if weather_response.daily:
|
||||
parse = False
|
||||
forecast = []
|
||||
for day in weather_response.daily[ATTR_DATA][0][AEMET_ATTR_FORECAST][
|
||||
AEMET_ATTR_DAY
|
||||
]:
|
||||
day_date = dt_util.parse_datetime(day[AEMET_ATTR_DATE])
|
||||
if now.date() == day_date.date():
|
||||
parse = True
|
||||
if parse:
|
||||
cur_forecast = self._convert_forecast_day(day_date, day)
|
||||
if cur_forecast:
|
||||
forecast.append(cur_forecast)
|
||||
return forecast
|
||||
return None
|
||||
|
||||
def _get_hourly_forecast_from_weather_response(self, weather_response, now):
|
||||
if weather_response.hourly:
|
||||
parse = False
|
||||
hour = now.hour
|
||||
forecast = []
|
||||
for day in weather_response.hourly[ATTR_DATA][0][AEMET_ATTR_FORECAST][
|
||||
AEMET_ATTR_DAY
|
||||
]:
|
||||
day_date = dt_util.parse_datetime(day[AEMET_ATTR_DATE])
|
||||
hour_start = 0
|
||||
if now.date() == day_date.date():
|
||||
parse = True
|
||||
hour_start = now.hour
|
||||
if parse:
|
||||
for hour in range(hour_start, 24):
|
||||
cur_forecast = self._convert_forecast_hour(day_date, day, hour)
|
||||
if cur_forecast:
|
||||
forecast.append(cur_forecast)
|
||||
return forecast
|
||||
return None
|
||||
|
||||
def _convert_forecast_day(self, date, day):
|
||||
if not (condition := self._get_condition_day(day)):
|
||||
return None
|
||||
|
||||
return {
|
||||
ATTR_API_FORECAST_CONDITION: condition,
|
||||
ATTR_API_FORECAST_PRECIPITATION_PROBABILITY: self._get_precipitation_prob_day(
|
||||
day
|
||||
),
|
||||
ATTR_API_FORECAST_TEMP: self._get_temperature_day(day),
|
||||
ATTR_API_FORECAST_TEMP_LOW: self._get_temperature_low_day(day),
|
||||
ATTR_API_FORECAST_TIME: dt_util.as_utc(date).isoformat(),
|
||||
ATTR_API_FORECAST_WIND_SPEED: self._get_wind_speed_day(day),
|
||||
ATTR_API_FORECAST_WIND_BEARING: self._get_wind_bearing_day(day),
|
||||
}
|
||||
|
||||
def _convert_forecast_hour(self, date, day, hour):
|
||||
if not (condition := self._get_condition(day, hour)):
|
||||
return None
|
||||
|
||||
forecast_dt = date.replace(hour=hour, minute=0, second=0)
|
||||
|
||||
return {
|
||||
ATTR_API_FORECAST_CONDITION: condition,
|
||||
ATTR_API_FORECAST_PRECIPITATION: self._calc_precipitation(day, hour),
|
||||
ATTR_API_FORECAST_PRECIPITATION_PROBABILITY: self._calc_precipitation_prob(
|
||||
day, hour
|
||||
),
|
||||
ATTR_API_FORECAST_TEMP: self._get_temperature(day, hour),
|
||||
ATTR_API_FORECAST_TIME: dt_util.as_utc(forecast_dt).isoformat(),
|
||||
ATTR_API_FORECAST_WIND_MAX_SPEED: self._get_wind_max_speed(day, hour),
|
||||
ATTR_API_FORECAST_WIND_SPEED: self._get_wind_speed(day, hour),
|
||||
ATTR_API_FORECAST_WIND_BEARING: self._get_wind_bearing(day, hour),
|
||||
}
|
||||
|
||||
def _calc_precipitation(self, day, hour):
|
||||
"""Calculate the precipitation."""
|
||||
rain_value = self._get_rain(day, hour) or 0
|
||||
snow_value = self._get_snow(day, hour) or 0
|
||||
|
||||
if round(rain_value + snow_value, 1) == 0:
|
||||
return None
|
||||
return round(rain_value + snow_value, 1)
|
||||
|
||||
def _calc_precipitation_prob(self, day, hour):
|
||||
"""Calculate the precipitation probability (hour)."""
|
||||
rain_value = self._get_rain_prob(day, hour) or 0
|
||||
snow_value = self._get_snow_prob(day, hour) or 0
|
||||
|
||||
if rain_value == 0 and snow_value == 0:
|
||||
return None
|
||||
return max(rain_value, snow_value)
|
||||
|
||||
@staticmethod
|
||||
def _get_condition(day_data, hour):
|
||||
"""Get weather condition (hour) from weather data."""
|
||||
val = get_forecast_hour_value(day_data[AEMET_ATTR_SKY_STATE], hour)
|
||||
if val:
|
||||
return format_condition(val)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_condition_day(day_data):
|
||||
"""Get weather condition (day) from weather data."""
|
||||
val = get_forecast_day_value(day_data[AEMET_ATTR_SKY_STATE])
|
||||
if val:
|
||||
return format_condition(val)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_humidity(day_data, hour):
|
||||
"""Get humidity from weather data."""
|
||||
val = get_forecast_hour_value(day_data[AEMET_ATTR_HUMIDITY], hour)
|
||||
if val:
|
||||
return format_int(val)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_precipitation_prob_day(day_data):
|
||||
"""Get humidity from weather data."""
|
||||
val = get_forecast_day_value(day_data[AEMET_ATTR_PRECIPITATION_PROBABILITY])
|
||||
if val:
|
||||
return format_int(val)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_rain(day_data, hour):
|
||||
"""Get rain from weather data."""
|
||||
val = get_forecast_hour_value(day_data[AEMET_ATTR_PRECIPITATION], hour)
|
||||
if val:
|
||||
return format_float(val)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_rain_prob(day_data, hour):
|
||||
"""Get rain probability from weather data."""
|
||||
val = get_forecast_interval_value(
|
||||
day_data[AEMET_ATTR_PRECIPITATION_PROBABILITY], hour
|
||||
)
|
||||
if val:
|
||||
return format_int(val)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_snow(day_data, hour):
|
||||
"""Get snow from weather data."""
|
||||
val = get_forecast_hour_value(day_data[AEMET_ATTR_SNOW], hour)
|
||||
if val:
|
||||
return format_float(val)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_snow_prob(day_data, hour):
|
||||
"""Get snow probability from weather data."""
|
||||
val = get_forecast_interval_value(day_data[AEMET_ATTR_SNOW_PROBABILITY], hour)
|
||||
if val:
|
||||
return format_int(val)
|
||||
return None
|
||||
|
||||
def _get_station_id(self):
|
||||
"""Get station ID from weather data."""
|
||||
if self.aemet.station:
|
||||
return self.aemet.station.get_id()
|
||||
return None
|
||||
|
||||
def _get_station_name(self):
|
||||
"""Get station name from weather data."""
|
||||
if self.aemet.station:
|
||||
return self.aemet.station.get_name()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_storm_prob(day_data, hour):
|
||||
"""Get storm probability from weather data."""
|
||||
val = get_forecast_interval_value(day_data[AEMET_ATTR_STORM_PROBABILITY], hour)
|
||||
if val:
|
||||
return format_int(val)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_temperature(day_data, hour):
|
||||
"""Get temperature (hour) from weather data."""
|
||||
val = get_forecast_hour_value(day_data[AEMET_ATTR_TEMPERATURE], hour)
|
||||
return format_int(val)
|
||||
|
||||
@staticmethod
|
||||
def _get_temperature_day(day_data):
|
||||
"""Get temperature (day) from weather data."""
|
||||
val = get_forecast_day_value(
|
||||
day_data[AEMET_ATTR_TEMPERATURE], key=AEMET_ATTR_MAX
|
||||
)
|
||||
return format_int(val)
|
||||
|
||||
@staticmethod
|
||||
def _get_temperature_low_day(day_data):
|
||||
"""Get temperature (day) from weather data."""
|
||||
val = get_forecast_day_value(
|
||||
day_data[AEMET_ATTR_TEMPERATURE], key=AEMET_ATTR_MIN
|
||||
)
|
||||
return format_int(val)
|
||||
|
||||
@staticmethod
|
||||
def _get_temperature_feeling(day_data, hour):
|
||||
"""Get temperature from weather data."""
|
||||
val = get_forecast_hour_value(day_data[AEMET_ATTR_FEEL_TEMPERATURE], hour)
|
||||
return format_int(val)
|
||||
|
||||
def _get_town_id(self):
|
||||
"""Get town ID from weather data."""
|
||||
if self.aemet.town:
|
||||
return self.aemet.town.get_id()
|
||||
return None
|
||||
|
||||
def _get_town_name(self):
|
||||
"""Get town name from weather data."""
|
||||
if self.aemet.town:
|
||||
return self.aemet.town.get_name()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_wind_bearing(day_data, hour):
|
||||
"""Get wind bearing (hour) from weather data."""
|
||||
val = get_forecast_hour_value(
|
||||
day_data[AEMET_ATTR_WIND_GUST], hour, key=AEMET_ATTR_DIRECTION
|
||||
)[0]
|
||||
return ForecastValue.parse_wind_direction(val)
|
||||
|
||||
@staticmethod
|
||||
def _get_wind_bearing_day(day_data):
|
||||
"""Get wind bearing (day) from weather data."""
|
||||
val = get_forecast_day_value(
|
||||
day_data[AEMET_ATTR_WIND], key=AEMET_ATTR_DIRECTION
|
||||
)
|
||||
return ForecastValue.parse_wind_direction(val)
|
||||
|
||||
@staticmethod
|
||||
def _get_wind_max_speed(day_data, hour):
|
||||
"""Get wind max speed from weather data."""
|
||||
val = get_forecast_hour_value(day_data[AEMET_ATTR_WIND_GUST], hour)
|
||||
if val:
|
||||
return format_int(val)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_wind_speed(day_data, hour):
|
||||
"""Get wind speed (hour) from weather data."""
|
||||
val = get_forecast_hour_value(
|
||||
day_data[AEMET_ATTR_WIND_GUST], hour, key=AEMET_ATTR_SPEED
|
||||
)[0]
|
||||
if val:
|
||||
return format_int(val)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_wind_speed_day(day_data):
|
||||
"""Get wind speed (day) from weather data."""
|
||||
val = get_forecast_day_value(day_data[AEMET_ATTR_WIND], key=AEMET_ATTR_SPEED)
|
||||
if val:
|
||||
return format_int(val)
|
||||
return None
|
@@ -18,8 +18,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import CONNECTION, DOMAIN as AGENT_DOMAIN
|
||||
|
||||
ICON = "mdi:security"
|
||||
|
||||
CONF_HOME_MODE_NAME = "home"
|
||||
CONF_AWAY_MODE_NAME = "away"
|
||||
CONF_NIGHT_MODE_NAME = "night"
|
||||
@@ -41,7 +39,6 @@ async def async_setup_entry(
|
||||
class AgentBaseStation(AlarmControlPanelEntity):
|
||||
"""Representation of an Agent DVR Alarm Control Panel."""
|
||||
|
||||
_attr_icon = ICON
|
||||
_attr_supported_features = (
|
||||
AlarmControlPanelEntityFeature.ARM_HOME
|
||||
| AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
|
7
homeassistant/components/air_quality/icons.json
Normal file
7
homeassistant/components/air_quality/icons.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"default": "mdi:air-filter"
|
||||
}
|
||||
}
|
||||
}
|
9
homeassistant/components/airly/icons.json
Normal file
9
homeassistant/components/airly/icons.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"caqi": {
|
||||
"default": "mdi:air-filter"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -66,7 +66,6 @@ class AirlySensorEntityDescription(SensorEntityDescription):
|
||||
SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = (
|
||||
AirlySensorEntityDescription(
|
||||
key=ATTR_API_CAQI,
|
||||
icon="mdi:air-filter",
|
||||
translation_key="caqi",
|
||||
native_unit_of_measurement="CAQI",
|
||||
suggested_display_precision=0,
|
||||
|
18
homeassistant/components/airnow/icons.json
Normal file
18
homeassistant/components/airnow/icons.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"aqi": {
|
||||
"default": "mdi:blur"
|
||||
},
|
||||
"pm25": {
|
||||
"default": "mdi:blur"
|
||||
},
|
||||
"o3": {
|
||||
"default": "mdi:blur"
|
||||
},
|
||||
"station": {
|
||||
"default": "mdi:blur"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -77,7 +77,7 @@ def station_extra_attrs(data: dict[str, Any]) -> dict[str, Any]:
|
||||
SENSOR_TYPES: tuple[AirNowEntityDescription, ...] = (
|
||||
AirNowEntityDescription(
|
||||
key=ATTR_API_AQI,
|
||||
icon="mdi:blur",
|
||||
translation_key="aqi",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.AQI,
|
||||
value_fn=lambda data: data.get(ATTR_API_AQI),
|
||||
@@ -94,7 +94,7 @@ SENSOR_TYPES: tuple[AirNowEntityDescription, ...] = (
|
||||
),
|
||||
AirNowEntityDescription(
|
||||
key=ATTR_API_PM25,
|
||||
icon="mdi:blur",
|
||||
translation_key="pm25",
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.PM25,
|
||||
@@ -104,7 +104,6 @@ SENSOR_TYPES: tuple[AirNowEntityDescription, ...] = (
|
||||
AirNowEntityDescription(
|
||||
key=ATTR_API_O3,
|
||||
translation_key="o3",
|
||||
icon="mdi:blur",
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.get(ATTR_API_O3),
|
||||
@@ -113,7 +112,6 @@ SENSOR_TYPES: tuple[AirNowEntityDescription, ...] = (
|
||||
AirNowEntityDescription(
|
||||
key=ATTR_API_STATION,
|
||||
translation_key="station",
|
||||
icon="mdi:blur",
|
||||
value_fn=lambda data: data.get(ATTR_API_STATION),
|
||||
extra_state_attributes_fn=station_extra_attrs,
|
||||
),
|
||||
|
@@ -56,4 +56,4 @@ class AirQCoordinator(DataUpdateCoordinator):
|
||||
hw_version=info["hw_version"],
|
||||
)
|
||||
)
|
||||
return await self.airq.get_latest_data()
|
||||
return await self.airq.get_latest_data() # type: ignore[no-any-return]
|
||||
|
24
homeassistant/components/airq/icons.json
Normal file
24
homeassistant/components/airq/icons.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"health_index": {
|
||||
"default": "mdi:heart-pulse"
|
||||
},
|
||||
"absolute_humidity": {
|
||||
"default": "mdi:water"
|
||||
},
|
||||
"oxygen": {
|
||||
"default": "mdi:leaf"
|
||||
},
|
||||
"performance_index": {
|
||||
"default": "mdi:head-check"
|
||||
},
|
||||
"radon": {
|
||||
"default": "mdi:radioactive"
|
||||
},
|
||||
"virus_index": {
|
||||
"default": "mdi:virus-off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -190,7 +190,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [
|
||||
translation_key="health_index",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
icon="mdi:heart-pulse",
|
||||
value=lambda data: data.get("health", 0.0) / 10.0,
|
||||
),
|
||||
AirQEntityDescription(
|
||||
@@ -206,7 +205,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [
|
||||
native_unit_of_measurement=CONCENTRATION_GRAMS_PER_CUBIC_METER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value=lambda data: data.get("humidity_abs"),
|
||||
icon="mdi:water",
|
||||
),
|
||||
AirQEntityDescription(
|
||||
key="h2_M1000",
|
||||
@@ -263,7 +261,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value=lambda data: data.get("oxygen"),
|
||||
icon="mdi:leaf",
|
||||
),
|
||||
AirQEntityDescription(
|
||||
key="o3",
|
||||
@@ -277,7 +274,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [
|
||||
translation_key="performance_index",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
icon="mdi:head-check",
|
||||
value=lambda data: data.get("performance", 0.0) / 10.0,
|
||||
),
|
||||
AirQEntityDescription(
|
||||
@@ -293,7 +289,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value=lambda data: data.get("pm1"),
|
||||
icon="mdi:dots-hexagon",
|
||||
),
|
||||
AirQEntityDescription(
|
||||
key="pm2_5",
|
||||
@@ -301,7 +296,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value=lambda data: data.get("pm2_5"),
|
||||
icon="mdi:dots-hexagon",
|
||||
),
|
||||
AirQEntityDescription(
|
||||
key="pm10",
|
||||
@@ -309,7 +303,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value=lambda data: data.get("pm10"),
|
||||
icon="mdi:dots-hexagon",
|
||||
),
|
||||
AirQEntityDescription(
|
||||
key="pressure",
|
||||
@@ -376,7 +369,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [
|
||||
native_unit_of_measurement=ACTIVITY_BECQUEREL_PER_CUBIC_METER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value=lambda data: data.get("radon"),
|
||||
icon="mdi:radioactive",
|
||||
),
|
||||
AirQEntityDescription(
|
||||
key="temperature",
|
||||
@@ -405,7 +397,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [
|
||||
translation_key="virus_index",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
icon="mdi:virus-off",
|
||||
value=lambda data: data.get("virus", 0.0),
|
||||
),
|
||||
]
|
||||
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from airthings import Airthings, AirthingsError
|
||||
from airthings import Airthings, AirthingsDevice, AirthingsError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ID, Platform
|
||||
@@ -19,6 +19,8 @@ _LOGGER = logging.getLogger(__name__)
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
SCAN_INTERVAL = timedelta(minutes=6)
|
||||
|
||||
AirthingsDataCoordinatorType = DataUpdateCoordinator[dict[str, AirthingsDevice]]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Airthings from a config entry."""
|
||||
@@ -30,10 +32,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async_get_clientsession(hass),
|
||||
)
|
||||
|
||||
async def _update_method():
|
||||
async def _update_method() -> dict[str, AirthingsDevice]:
|
||||
"""Get the latest data from Airthings."""
|
||||
try:
|
||||
return await airthings.update_devices()
|
||||
return await airthings.update_devices() # type: ignore[no-any-return]
|
||||
except AirthingsError as err:
|
||||
raise UpdateFailed(f"Unable to fetch data: {err}") from err
|
||||
|
||||
|
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"domain": "airthings",
|
||||
"name": "Airthings",
|
||||
"codeowners": ["@danielhiversen"],
|
||||
"codeowners": ["@danielhiversen", "@LaStrada"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/airthings",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["airthings"],
|
||||
"requirements": ["airthings-cloud==0.1.0"]
|
||||
"requirements": ["airthings-cloud==0.2.0"]
|
||||
}
|
||||
|
@@ -24,11 +24,9 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
)
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import AirthingsDataCoordinatorType
|
||||
from .const import DOMAIN
|
||||
|
||||
SENSORS: dict[str, SensorEntityDescription] = {
|
||||
@@ -108,7 +106,7 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Airthings sensor."""
|
||||
|
||||
coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
coordinator: AirthingsDataCoordinatorType = hass.data[DOMAIN][entry.entry_id]
|
||||
entities = [
|
||||
AirthingsHeaterEnergySensor(
|
||||
coordinator,
|
||||
@@ -122,7 +120,9 @@ async def async_setup_entry(
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class AirthingsHeaterEnergySensor(CoordinatorEntity, SensorEntity):
|
||||
class AirthingsHeaterEnergySensor(
|
||||
CoordinatorEntity[AirthingsDataCoordinatorType], SensorEntity
|
||||
):
|
||||
"""Representation of a Airthings Sensor device."""
|
||||
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
@@ -130,7 +130,7 @@ class AirthingsHeaterEnergySensor(CoordinatorEntity, SensorEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: DataUpdateCoordinator,
|
||||
coordinator: AirthingsDataCoordinatorType,
|
||||
airthings_device: AirthingsDevice,
|
||||
entity_description: SensorEntityDescription,
|
||||
) -> None:
|
||||
@@ -149,10 +149,10 @@ class AirthingsHeaterEnergySensor(CoordinatorEntity, SensorEntity):
|
||||
identifiers={(DOMAIN, airthings_device.device_id)},
|
||||
name=airthings_device.name,
|
||||
manufacturer="Airthings",
|
||||
model=airthings_device.device_type.replace("_", " ").lower().title(),
|
||||
model=airthings_device.product_name,
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the value reported by the sensor."""
|
||||
return self.coordinator.data[self._id].sensors[self.entity_description.key]
|
||||
return self.coordinator.data[self._id].sensors[self.entity_description.key] # type: ignore[no-any-return]
|
||||
|
18
homeassistant/components/airthings_ble/icons.json
Normal file
18
homeassistant/components/airthings_ble/icons.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"radon_1day_avg": {
|
||||
"default": "mdi:radioactive"
|
||||
},
|
||||
"radon_longterm_avg": {
|
||||
"default": "mdi:radioactive"
|
||||
},
|
||||
"radon_1day_level": {
|
||||
"default": "mdi:radioactive"
|
||||
},
|
||||
"radon_longterm_level": {
|
||||
"default": "mdi:radioactive"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -24,5 +24,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/airthings_ble",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["airthings-ble==0.6.0"]
|
||||
"requirements": ["airthings-ble==0.6.1"]
|
||||
}
|
||||
|
@@ -52,24 +52,20 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = {
|
||||
translation_key="radon_1day_avg",
|
||||
native_unit_of_measurement=VOLUME_BECQUEREL,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
icon="mdi:radioactive",
|
||||
),
|
||||
"radon_longterm_avg": SensorEntityDescription(
|
||||
key="radon_longterm_avg",
|
||||
translation_key="radon_longterm_avg",
|
||||
native_unit_of_measurement=VOLUME_BECQUEREL,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
icon="mdi:radioactive",
|
||||
),
|
||||
"radon_1day_level": SensorEntityDescription(
|
||||
key="radon_1day_level",
|
||||
translation_key="radon_1day_level",
|
||||
icon="mdi:radioactive",
|
||||
),
|
||||
"radon_longterm_level": SensorEntityDescription(
|
||||
key="radon_longterm_level",
|
||||
translation_key="radon_longterm_level",
|
||||
icon="mdi:radioactive",
|
||||
),
|
||||
"temperature": SensorEntityDescription(
|
||||
key="temperature",
|
||||
@@ -107,7 +103,6 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = {
|
||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
icon="mdi:cloud",
|
||||
),
|
||||
"illuminance": SensorEntityDescription(
|
||||
key="illuminance",
|
||||
|
@@ -88,9 +88,13 @@ class AirtouchAC(CoordinatorEntity, ClimateEntity):
|
||||
_attr_name = None
|
||||
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.FAN_MODE
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
)
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
|
||||
def __init__(self, coordinator, ac_number, info):
|
||||
"""Initialize the climate device."""
|
||||
@@ -192,9 +196,14 @@ class AirtouchGroup(CoordinatorEntity, ClimateEntity):
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
)
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_hvac_modes = AT_GROUP_MODES
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
|
||||
def __init__(self, coordinator, group_number, info):
|
||||
"""Initialize the climate device."""
|
||||
|
50
homeassistant/components/airtouch5/__init__.py
Normal file
50
homeassistant/components/airtouch5/__init__.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""The Airtouch 5 integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from airtouch5py.airtouch5_simple_client import Airtouch5SimpleClient
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.CLIMATE]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Airtouch 5 from a config entry."""
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
# Create API instance
|
||||
host = entry.data[CONF_HOST]
|
||||
client = Airtouch5SimpleClient(host)
|
||||
|
||||
# Connect to the API
|
||||
try:
|
||||
await client.connect_and_stay_connected()
|
||||
except TimeoutError as t:
|
||||
raise ConfigEntryNotReady() from t
|
||||
|
||||
# Store an API object for your platforms to access
|
||||
hass.data[DOMAIN][entry.entry_id] = client
|
||||
|
||||
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."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
client: Airtouch5SimpleClient = hass.data[DOMAIN][entry.entry_id]
|
||||
await client.disconnect()
|
||||
client.ac_status_callbacks.clear()
|
||||
client.connection_state_callbacks.clear()
|
||||
client.data_packet_callbacks.clear()
|
||||
client.zone_status_callbacks.clear()
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unload_ok
|
379
homeassistant/components/airtouch5/climate.py
Normal file
379
homeassistant/components/airtouch5/climate.py
Normal file
@@ -0,0 +1,379 @@
|
||||
"""AirTouch 5 component to control AirTouch 5 Climate Devices."""
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from airtouch5py.airtouch5_simple_client import Airtouch5SimpleClient
|
||||
from airtouch5py.packets.ac_ability import AcAbility
|
||||
from airtouch5py.packets.ac_control import (
|
||||
AcControl,
|
||||
SetAcFanSpeed,
|
||||
SetAcMode,
|
||||
SetpointControl,
|
||||
SetPowerSetting,
|
||||
)
|
||||
from airtouch5py.packets.ac_status import AcFanSpeed, AcMode, AcPowerState, AcStatus
|
||||
from airtouch5py.packets.zone_control import (
|
||||
ZoneControlZone,
|
||||
ZoneSettingPower,
|
||||
ZoneSettingValue,
|
||||
)
|
||||
from airtouch5py.packets.zone_name import ZoneName
|
||||
from airtouch5py.packets.zone_status import ZonePowerState, ZoneStatusZone
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
FAN_AUTO,
|
||||
FAN_DIFFUSE,
|
||||
FAN_FOCUS,
|
||||
FAN_HIGH,
|
||||
FAN_LOW,
|
||||
FAN_MEDIUM,
|
||||
PRESET_BOOST,
|
||||
PRESET_NONE,
|
||||
ClimateEntity,
|
||||
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 .const import DOMAIN, FAN_INTELLIGENT_AUTO, FAN_TURBO
|
||||
from .entity import Airtouch5Entity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
AC_MODE_TO_HVAC_MODE = {
|
||||
AcMode.AUTO: HVACMode.AUTO,
|
||||
AcMode.AUTO_COOL: HVACMode.AUTO,
|
||||
AcMode.AUTO_HEAT: HVACMode.AUTO,
|
||||
AcMode.COOL: HVACMode.COOL,
|
||||
AcMode.DRY: HVACMode.DRY,
|
||||
AcMode.FAN: HVACMode.FAN_ONLY,
|
||||
AcMode.HEAT: HVACMode.HEAT,
|
||||
}
|
||||
HVAC_MODE_TO_SET_AC_MODE = {
|
||||
HVACMode.AUTO: SetAcMode.SET_TO_AUTO,
|
||||
HVACMode.COOL: SetAcMode.SET_TO_COOL,
|
||||
HVACMode.DRY: SetAcMode.SET_TO_DRY,
|
||||
HVACMode.FAN_ONLY: SetAcMode.SET_TO_FAN,
|
||||
HVACMode.HEAT: SetAcMode.SET_TO_HEAT,
|
||||
}
|
||||
|
||||
|
||||
AC_FAN_SPEED_TO_FAN_SPEED = {
|
||||
AcFanSpeed.AUTO: FAN_AUTO,
|
||||
AcFanSpeed.QUIET: FAN_DIFFUSE,
|
||||
AcFanSpeed.LOW: FAN_LOW,
|
||||
AcFanSpeed.MEDIUM: FAN_MEDIUM,
|
||||
AcFanSpeed.HIGH: FAN_HIGH,
|
||||
AcFanSpeed.POWERFUL: FAN_FOCUS,
|
||||
AcFanSpeed.TURBO: FAN_TURBO,
|
||||
AcFanSpeed.INTELLIGENT_AUTO_1: FAN_INTELLIGENT_AUTO,
|
||||
AcFanSpeed.INTELLIGENT_AUTO_2: FAN_INTELLIGENT_AUTO,
|
||||
AcFanSpeed.INTELLIGENT_AUTO_3: FAN_INTELLIGENT_AUTO,
|
||||
AcFanSpeed.INTELLIGENT_AUTO_4: FAN_INTELLIGENT_AUTO,
|
||||
AcFanSpeed.INTELLIGENT_AUTO_5: FAN_INTELLIGENT_AUTO,
|
||||
AcFanSpeed.INTELLIGENT_AUTO_6: FAN_INTELLIGENT_AUTO,
|
||||
}
|
||||
FAN_MODE_TO_SET_AC_FAN_SPEED = {
|
||||
FAN_AUTO: SetAcFanSpeed.SET_TO_AUTO,
|
||||
FAN_DIFFUSE: SetAcFanSpeed.SET_TO_QUIET,
|
||||
FAN_LOW: SetAcFanSpeed.SET_TO_LOW,
|
||||
FAN_MEDIUM: SetAcFanSpeed.SET_TO_MEDIUM,
|
||||
FAN_HIGH: SetAcFanSpeed.SET_TO_HIGH,
|
||||
FAN_FOCUS: SetAcFanSpeed.SET_TO_POWERFUL,
|
||||
FAN_TURBO: SetAcFanSpeed.SET_TO_TURBO,
|
||||
FAN_INTELLIGENT_AUTO: SetAcFanSpeed.SET_TO_INTELLIGENT_AUTO,
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Airtouch 5 Climate entities."""
|
||||
client: Airtouch5SimpleClient = hass.data[DOMAIN][config_entry.entry_id]
|
||||
|
||||
entities: list[ClimateEntity] = []
|
||||
|
||||
# Add each AC (and remember what zones they apply to).
|
||||
# Each zone is controlled by a single AC
|
||||
zone_to_ac: dict[int, AcAbility] = {}
|
||||
for ac in client.ac:
|
||||
for i in range(ac.start_zone_number, ac.start_zone_number + ac.zone_count):
|
||||
zone_to_ac[i] = ac
|
||||
entities.append(Airtouch5AC(client, ac))
|
||||
|
||||
# Add each zone
|
||||
for zone in client.zones:
|
||||
entities.append(Airtouch5Zone(client, zone, zone_to_ac[zone.zone_number]))
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class Airtouch5ClimateEntity(ClimateEntity, Airtouch5Entity):
|
||||
"""Base class for Airtouch5 Climate Entities."""
|
||||
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_target_temperature_step = 1
|
||||
_attr_name = None
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
|
||||
|
||||
class Airtouch5AC(Airtouch5ClimateEntity):
|
||||
"""Representation of the AC unit. Used to control the overall HVAC Mode."""
|
||||
|
||||
def __init__(self, client: Airtouch5SimpleClient, ability: AcAbility) -> None:
|
||||
"""Initialise the Climate Entity."""
|
||||
super().__init__(client)
|
||||
self._ability = ability
|
||||
self._attr_unique_id = f"ac_{ability.ac_number}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"ac_{ability.ac_number}")},
|
||||
name=f"AC {ability.ac_number}",
|
||||
manufacturer="Polyaire",
|
||||
model="AirTouch 5",
|
||||
)
|
||||
self._attr_hvac_modes = [HVACMode.OFF]
|
||||
if ability.supports_mode_auto:
|
||||
self._attr_hvac_modes.append(HVACMode.AUTO)
|
||||
if ability.supports_mode_cool:
|
||||
self._attr_hvac_modes.append(HVACMode.COOL)
|
||||
if ability.supports_mode_dry:
|
||||
self._attr_hvac_modes.append(HVACMode.DRY)
|
||||
if ability.supports_mode_fan:
|
||||
self._attr_hvac_modes.append(HVACMode.FAN_ONLY)
|
||||
if ability.supports_mode_heat:
|
||||
self._attr_hvac_modes.append(HVACMode.HEAT)
|
||||
|
||||
self._attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE
|
||||
)
|
||||
if len(self.hvac_modes) > 1:
|
||||
self._attr_supported_features |= (
|
||||
ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
|
||||
)
|
||||
|
||||
self._attr_fan_modes = []
|
||||
if ability.supports_fan_speed_quiet:
|
||||
self._attr_fan_modes.append(FAN_DIFFUSE)
|
||||
if ability.supports_fan_speed_low:
|
||||
self._attr_fan_modes.append(FAN_LOW)
|
||||
if ability.supports_fan_speed_medium:
|
||||
self._attr_fan_modes.append(FAN_MEDIUM)
|
||||
if ability.supports_fan_speed_high:
|
||||
self._attr_fan_modes.append(FAN_HIGH)
|
||||
if ability.supports_fan_speed_powerful:
|
||||
self._attr_fan_modes.append(FAN_FOCUS)
|
||||
if ability.supports_fan_speed_turbo:
|
||||
self._attr_fan_modes.append(FAN_TURBO)
|
||||
if ability.supports_fan_speed_auto:
|
||||
self._attr_fan_modes.append(FAN_AUTO)
|
||||
if ability.supports_fan_speed_intelligent_auto:
|
||||
self._attr_fan_modes.append(FAN_INTELLIGENT_AUTO)
|
||||
|
||||
# We can have different setpoints for heat cool, we expose the lowest low and highest high
|
||||
self._attr_min_temp = min(
|
||||
ability.min_cool_set_point, ability.min_heat_set_point
|
||||
)
|
||||
self._attr_max_temp = max(
|
||||
ability.max_cool_set_point, ability.max_heat_set_point
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_update_attrs(self, data: dict[int, AcStatus]) -> None:
|
||||
if self._ability.ac_number not in data:
|
||||
return
|
||||
status = data[self._ability.ac_number]
|
||||
|
||||
self._attr_current_temperature = status.temperature
|
||||
self._attr_target_temperature = status.ac_setpoint
|
||||
if status.ac_power_state in [AcPowerState.OFF, AcPowerState.AWAY_OFF]:
|
||||
self._attr_hvac_mode = HVACMode.OFF
|
||||
else:
|
||||
self._attr_hvac_mode = AC_MODE_TO_HVAC_MODE[status.ac_mode]
|
||||
self._attr_fan_mode = AC_FAN_SPEED_TO_FAN_SPEED[status.ac_fan_speed]
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Add data updated listener after this object has been initialized."""
|
||||
await super().async_added_to_hass()
|
||||
self._client.ac_status_callbacks.append(self._async_update_attrs)
|
||||
self._async_update_attrs(self._client.latest_ac_status)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Remove data updated listener after this object has been initialized."""
|
||||
await super().async_will_remove_from_hass()
|
||||
self._client.ac_status_callbacks.remove(self._async_update_attrs)
|
||||
|
||||
async def _control(
|
||||
self,
|
||||
*,
|
||||
power: SetPowerSetting = SetPowerSetting.KEEP_POWER_SETTING,
|
||||
ac_mode: SetAcMode = SetAcMode.KEEP_AC_MODE,
|
||||
fan: SetAcFanSpeed = SetAcFanSpeed.KEEP_AC_FAN_SPEED,
|
||||
setpoint: SetpointControl = SetpointControl.KEEP_SETPOINT_VALUE,
|
||||
temp: int = 0,
|
||||
) -> None:
|
||||
control = AcControl(
|
||||
power,
|
||||
self._ability.ac_number,
|
||||
ac_mode,
|
||||
fan,
|
||||
setpoint,
|
||||
temp,
|
||||
)
|
||||
packet = self._client.data_packet_factory.ac_control([control])
|
||||
await self._client.send_packet(packet)
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new operation mode."""
|
||||
set_power_setting: SetPowerSetting
|
||||
set_ac_mode: SetAcMode
|
||||
|
||||
if hvac_mode == HVACMode.OFF:
|
||||
set_power_setting = SetPowerSetting.SET_TO_OFF
|
||||
set_ac_mode = SetAcMode.KEEP_AC_MODE
|
||||
else:
|
||||
set_power_setting = SetPowerSetting.SET_TO_ON
|
||||
if hvac_mode not in HVAC_MODE_TO_SET_AC_MODE:
|
||||
raise ValueError(f"Unsupported hvac mode: {hvac_mode}")
|
||||
set_ac_mode = HVAC_MODE_TO_SET_AC_MODE[hvac_mode]
|
||||
|
||||
await self._control(power=set_power_setting, ac_mode=set_ac_mode)
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set new fan mode."""
|
||||
if fan_mode not in FAN_MODE_TO_SET_AC_FAN_SPEED:
|
||||
raise ValueError(f"Unsupported fan mode: {fan_mode}")
|
||||
fan_speed = FAN_MODE_TO_SET_AC_FAN_SPEED[fan_mode]
|
||||
await self._control(fan=fan_speed)
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperatures."""
|
||||
if (temp := kwargs.get(ATTR_TEMPERATURE)) is None:
|
||||
_LOGGER.debug("Argument `temperature` is missing in set_temperature")
|
||||
return
|
||||
|
||||
await self._control(temp=temp)
|
||||
|
||||
|
||||
class Airtouch5Zone(Airtouch5ClimateEntity):
|
||||
"""Representation of a Zone. Used to control the AC effect in the zone."""
|
||||
|
||||
_attr_hvac_modes = [HVACMode.OFF, HVACMode.FAN_ONLY]
|
||||
_attr_preset_modes = [PRESET_NONE, PRESET_BOOST]
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.PRESET_MODE
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self, client: Airtouch5SimpleClient, name: ZoneName, ac: AcAbility
|
||||
) -> None:
|
||||
"""Initialise the Climate Entity."""
|
||||
super().__init__(client)
|
||||
self._name = name
|
||||
|
||||
self._attr_unique_id = f"zone_{name.zone_number}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"zone_{name.zone_number}")},
|
||||
name=name.zone_name,
|
||||
manufacturer="Polyaire",
|
||||
model="AirTouch 5",
|
||||
)
|
||||
# We can have different setpoints for heat and cool, we expose the lowest low and highest high
|
||||
self._attr_min_temp = min(ac.min_cool_set_point, ac.min_heat_set_point)
|
||||
self._attr_max_temp = max(ac.max_cool_set_point, ac.max_heat_set_point)
|
||||
|
||||
@callback
|
||||
def _async_update_attrs(self, data: dict[int, ZoneStatusZone]) -> None:
|
||||
if self._name.zone_number not in data:
|
||||
return
|
||||
status = data[self._name.zone_number]
|
||||
self._attr_current_temperature = status.temperature
|
||||
self._attr_target_temperature = status.set_point
|
||||
|
||||
if status.zone_power_state == ZonePowerState.OFF:
|
||||
self._attr_hvac_mode = HVACMode.OFF
|
||||
self._attr_preset_mode = PRESET_NONE
|
||||
elif status.zone_power_state == ZonePowerState.ON:
|
||||
self._attr_hvac_mode = HVACMode.FAN_ONLY
|
||||
self._attr_preset_mode = PRESET_NONE
|
||||
elif status.zone_power_state == ZonePowerState.TURBO:
|
||||
self._attr_hvac_mode = HVACMode.FAN_ONLY
|
||||
self._attr_preset_mode = PRESET_BOOST
|
||||
else:
|
||||
self._attr_hvac_mode = None
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Add data updated listener after this object has been initialized."""
|
||||
await super().async_added_to_hass()
|
||||
self._client.zone_status_callbacks.append(self._async_update_attrs)
|
||||
self._async_update_attrs(self._client.latest_zone_status)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Remove data updated listener after this object has been initialized."""
|
||||
await super().async_will_remove_from_hass()
|
||||
self._client.zone_status_callbacks.remove(self._async_update_attrs)
|
||||
|
||||
async def _control(
|
||||
self,
|
||||
*,
|
||||
zsv: ZoneSettingValue = ZoneSettingValue.KEEP_SETTING_VALUE,
|
||||
power: ZoneSettingPower = ZoneSettingPower.KEEP_POWER_STATE,
|
||||
value: float = 0,
|
||||
) -> None:
|
||||
control = ZoneControlZone(self._name.zone_number, zsv, power, value)
|
||||
packet = self._client.data_packet_factory.zone_control([control])
|
||||
await self._client.send_packet(packet)
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new operation mode."""
|
||||
power: ZoneSettingPower
|
||||
|
||||
if hvac_mode is HVACMode.OFF:
|
||||
power = ZoneSettingPower.SET_TO_OFF
|
||||
elif self._attr_preset_mode is PRESET_BOOST:
|
||||
power = ZoneSettingPower.SET_TO_TURBO
|
||||
else:
|
||||
power = ZoneSettingPower.SET_TO_ON
|
||||
|
||||
await self._control(power=power)
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Enable or disable Turbo. Done this way as we can't have a turbo HVACMode."""
|
||||
power: ZoneSettingPower
|
||||
if preset_mode == PRESET_BOOST:
|
||||
power = ZoneSettingPower.SET_TO_TURBO
|
||||
else:
|
||||
power = ZoneSettingPower.SET_TO_ON
|
||||
|
||||
await self._control(power=power)
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperatures."""
|
||||
|
||||
if (temp := kwargs.get(ATTR_TEMPERATURE)) is None:
|
||||
_LOGGER.debug("Argument `temperature` is missing in set_temperature")
|
||||
return
|
||||
|
||||
await self._control(
|
||||
zsv=ZoneSettingValue.SET_TARGET_SETPOINT,
|
||||
value=float(temp),
|
||||
)
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Turn the zone on."""
|
||||
await self.async_set_hvac_mode(HVACMode.FAN_ONLY)
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn the zone off."""
|
||||
await self.async_set_hvac_mode(HVACMode.OFF)
|
46
homeassistant/components/airtouch5/config_flow.py
Normal file
46
homeassistant/components/airtouch5/config_flow.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Config flow for Airtouch 5 integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from airtouch5py.airtouch5_simple_client import Airtouch5SimpleClient
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
|
||||
|
||||
|
||||
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Airtouch 5."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] | None = None
|
||||
if user_input is not None:
|
||||
client = Airtouch5SimpleClient(user_input[CONF_HOST])
|
||||
try:
|
||||
await client.test_connection()
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
errors = {"base": "cannot_connect"}
|
||||
else:
|
||||
await self.async_set_unique_id(user_input[CONF_HOST])
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_HOST], data=user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
6
homeassistant/components/airtouch5/const.py
Normal file
6
homeassistant/components/airtouch5/const.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Constants for the Airtouch 5 integration."""
|
||||
|
||||
DOMAIN = "airtouch5"
|
||||
|
||||
FAN_TURBO = "turbo"
|
||||
FAN_INTELLIGENT_AUTO = "intelligent_auto"
|
40
homeassistant/components/airtouch5/entity.py
Normal file
40
homeassistant/components/airtouch5/entity.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Base class for Airtouch5 entities."""
|
||||
from airtouch5py.airtouch5_client import Airtouch5ConnectionStateChange
|
||||
from airtouch5py.airtouch5_simple_client import Airtouch5SimpleClient
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class Airtouch5Entity(Entity):
|
||||
"""Base class for Airtouch5 entities."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = DOMAIN
|
||||
|
||||
def __init__(self, client: Airtouch5SimpleClient) -> None:
|
||||
"""Initialise the Entity."""
|
||||
self._client = client
|
||||
self._attr_available = True
|
||||
|
||||
@callback
|
||||
def _receive_connection_callback(
|
||||
self, state: Airtouch5ConnectionStateChange
|
||||
) -> None:
|
||||
self._attr_available = state is Airtouch5ConnectionStateChange.CONNECTED
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Add data updated listener after this object has been initialized."""
|
||||
self._client.connection_state_callbacks.append(
|
||||
self._receive_connection_callback
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Remove data updated listener when entity is removed from homeassistant."""
|
||||
self._client.connection_state_callbacks.remove(
|
||||
self._receive_connection_callback
|
||||
)
|
10
homeassistant/components/airtouch5/manifest.json
Normal file
10
homeassistant/components/airtouch5/manifest.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"domain": "airtouch5",
|
||||
"name": "AirTouch 5",
|
||||
"codeowners": ["@danzel"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/airtouch5",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["airtouch5py"],
|
||||
"requirements": ["airtouch5py==0.2.8"]
|
||||
}
|
32
homeassistant/components/airtouch5/strings.json
Normal file
32
homeassistant/components/airtouch5/strings.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"climate": {
|
||||
"airtouch5": {
|
||||
"state_attributes": {
|
||||
"fan_mode": {
|
||||
"state": {
|
||||
"turbo": "Turbo",
|
||||
"intelligent_auto": "Intelligent Auto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -26,22 +26,15 @@ from . import AirVisualProData, AirVisualProEntity
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AirVisualProMeasurementKeyMixin:
|
||||
"""Define an entity description mixin to include a measurement key."""
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AirVisualProMeasurementDescription(SensorEntityDescription):
|
||||
"""Describe an AirVisual Pro sensor."""
|
||||
|
||||
value_fn: Callable[
|
||||
[dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]], float | int
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AirVisualProMeasurementDescription(
|
||||
SensorEntityDescription, AirVisualProMeasurementKeyMixin
|
||||
):
|
||||
"""Describe an AirVisual Pro sensor."""
|
||||
|
||||
|
||||
SENSOR_DESCRIPTIONS = (
|
||||
AirVisualProMeasurementDescription(
|
||||
key="air_quality_index",
|
||||
@@ -67,6 +60,7 @@ SENSOR_DESCRIPTIONS = (
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda settings, status, measurements, history: status["battery"],
|
||||
),
|
||||
AirVisualProMeasurementDescription(
|
||||
@@ -80,6 +74,7 @@ SENSOR_DESCRIPTIONS = (
|
||||
key="humidity",
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda settings, status, measurements, history: measurements[
|
||||
"humidity"
|
||||
],
|
||||
|
@@ -117,6 +117,7 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity):
|
||||
_attr_name = None
|
||||
_speeds: dict[int, str] = {}
|
||||
_speeds_reverse: dict[str, int] = {}
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -129,7 +130,11 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity):
|
||||
super().__init__(coordinator, entry, system_zone_id, zone_data)
|
||||
|
||||
self._attr_unique_id = f"{self._attr_unique_id}_{system_zone_id}"
|
||||
self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
self._attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
)
|
||||
self._attr_target_temperature_step = API_TEMPERATURE_STEP
|
||||
self._attr_temperature_unit = TEMP_UNIT_LIB_TO_HASS[
|
||||
self.get_airzone_value(AZD_TEMP_UNIT)
|
||||
|
@@ -144,8 +144,8 @@ class AirzoneClimate(AirzoneEntity, ClimateEntity):
|
||||
"""Define an Airzone Cloud climate."""
|
||||
|
||||
_attr_name = None
|
||||
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
@@ -175,6 +175,12 @@ class AirzoneClimate(AirzoneEntity, ClimateEntity):
|
||||
class AirzoneDeviceClimate(AirzoneClimate):
|
||||
"""Define an Airzone Cloud Device base class."""
|
||||
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
)
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Turn the entity on."""
|
||||
params = {
|
||||
@@ -212,6 +218,12 @@ class AirzoneDeviceClimate(AirzoneClimate):
|
||||
class AirzoneDeviceGroupClimate(AirzoneClimate):
|
||||
"""Define an Airzone Cloud DeviceGroup base class."""
|
||||
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
)
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Turn the entity on."""
|
||||
params = {
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioairzone_cloud"],
|
||||
"requirements": ["aioairzone-cloud==0.3.6"]
|
||||
"requirements": ["aioairzone-cloud==0.3.8"]
|
||||
}
|
||||
|
26
homeassistant/components/alarm_control_panel/icons.json
Normal file
26
homeassistant/components/alarm_control_panel/icons.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"default": "mdi:shield",
|
||||
"state": {
|
||||
"armed_away": "mdi:shield-lock",
|
||||
"armed_custom_bypass": "mdi:security",
|
||||
"armed_home": "mdi:shield-home",
|
||||
"armed_night": "mdi:shield-moon",
|
||||
"armed_vacation": "mdi:shield-airplane",
|
||||
"disarmed": "mdi:shield-off",
|
||||
"pending": "mdi:shield-outline",
|
||||
"triggered": "mdi:bell-ring"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"alarm_arm_away": "mdi:shield-lock",
|
||||
"alarm_arm_home": "mdi:shield-home",
|
||||
"alarm_arm_night": "mdi:shield-moon",
|
||||
"alarm_custom_bypass": "mdi:security",
|
||||
"alarm_disarm": "mdi:shield-off",
|
||||
"alarm_trigger": "mdi:bell-ring",
|
||||
"arlam_arm_vacation": "mdi:shield-airplane"
|
||||
}
|
||||
}
|
@@ -39,8 +39,8 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.ALARM_CONTROL_PANEL,
|
||||
Platform.SENSOR,
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.SENSOR,
|
||||
]
|
||||
|
||||
|
||||
|
@@ -380,12 +380,17 @@ def async_get_entities(
|
||||
if state.domain not in ENTITY_ADAPTERS:
|
||||
continue
|
||||
|
||||
alexa_entity = ENTITY_ADAPTERS[state.domain](hass, config, state)
|
||||
|
||||
if not list(alexa_entity.interfaces()):
|
||||
continue
|
||||
|
||||
entities.append(alexa_entity)
|
||||
try:
|
||||
alexa_entity = ENTITY_ADAPTERS[state.domain](hass, config, state)
|
||||
interfaces = list(alexa_entity.interfaces())
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
_LOGGER.exception(
|
||||
"Unable to serialize %s for discovery: %s", state.entity_id, exc
|
||||
)
|
||||
else:
|
||||
if not interfaces:
|
||||
continue
|
||||
entities.append(alexa_entity)
|
||||
|
||||
return entities
|
||||
|
||||
@@ -406,13 +411,11 @@ class GenericCapabilities(AlexaEntity):
|
||||
|
||||
return [DisplayCategory.OTHER]
|
||||
|
||||
def interfaces(self) -> list[AlexaCapability]:
|
||||
def interfaces(self) -> Generator[AlexaCapability, None, None]:
|
||||
"""Yield the supported interfaces."""
|
||||
return [
|
||||
AlexaPowerController(self.entity),
|
||||
AlexaEndpointHealth(self.hass, self.entity),
|
||||
Alexa(self.entity),
|
||||
]
|
||||
yield AlexaPowerController(self.entity)
|
||||
yield AlexaEndpointHealth(self.hass, self.entity)
|
||||
yield Alexa(self.entity)
|
||||
|
||||
|
||||
@ENTITY_ADAPTERS.register(input_boolean.DOMAIN)
|
||||
@@ -431,14 +434,12 @@ class SwitchCapabilities(AlexaEntity):
|
||||
|
||||
return [DisplayCategory.SWITCH]
|
||||
|
||||
def interfaces(self) -> list[AlexaCapability]:
|
||||
def interfaces(self) -> Generator[AlexaCapability, None, None]:
|
||||
"""Yield the supported interfaces."""
|
||||
return [
|
||||
AlexaPowerController(self.entity),
|
||||
AlexaContactSensor(self.hass, self.entity),
|
||||
AlexaEndpointHealth(self.hass, self.entity),
|
||||
Alexa(self.entity),
|
||||
]
|
||||
yield AlexaPowerController(self.entity)
|
||||
yield AlexaContactSensor(self.hass, self.entity)
|
||||
yield AlexaEndpointHealth(self.hass, self.entity)
|
||||
yield Alexa(self.entity)
|
||||
|
||||
|
||||
@ENTITY_ADAPTERS.register(button.DOMAIN)
|
||||
@@ -450,14 +451,12 @@ class ButtonCapabilities(AlexaEntity):
|
||||
"""Return the display categories for this entity."""
|
||||
return [DisplayCategory.ACTIVITY_TRIGGER]
|
||||
|
||||
def interfaces(self) -> list[AlexaCapability]:
|
||||
def interfaces(self) -> Generator[AlexaCapability, None, None]:
|
||||
"""Yield the supported interfaces."""
|
||||
return [
|
||||
AlexaSceneController(self.entity, supports_deactivation=False),
|
||||
AlexaEventDetectionSensor(self.hass, self.entity),
|
||||
AlexaEndpointHealth(self.hass, self.entity),
|
||||
Alexa(self.entity),
|
||||
]
|
||||
yield AlexaSceneController(self.entity, supports_deactivation=False)
|
||||
yield AlexaEventDetectionSensor(self.hass, self.entity)
|
||||
yield AlexaEndpointHealth(self.hass, self.entity)
|
||||
yield Alexa(self.entity)
|
||||
|
||||
|
||||
@ENTITY_ADAPTERS.register(climate.DOMAIN)
|
||||
@@ -479,6 +478,14 @@ class ClimateCapabilities(AlexaEntity):
|
||||
self.entity.domain == climate.DOMAIN
|
||||
and climate.HVACMode.OFF
|
||||
in (self.entity.attributes.get(climate.ATTR_HVAC_MODES) or [])
|
||||
or self.entity.domain == climate.DOMAIN
|
||||
and (
|
||||
supported_features
|
||||
& (
|
||||
climate.ClimateEntityFeature.TURN_ON
|
||||
| climate.ClimateEntityFeature.TURN_OFF
|
||||
)
|
||||
)
|
||||
or self.entity.domain == water_heater.DOMAIN
|
||||
and (supported_features & water_heater.WaterHeaterEntityFeature.ON_OFF)
|
||||
):
|
||||
@@ -676,13 +683,11 @@ class LockCapabilities(AlexaEntity):
|
||||
"""Return the display categories for this entity."""
|
||||
return [DisplayCategory.SMARTLOCK]
|
||||
|
||||
def interfaces(self) -> list[AlexaCapability]:
|
||||
def interfaces(self) -> Generator[AlexaCapability, None, None]:
|
||||
"""Yield the supported interfaces."""
|
||||
return [
|
||||
AlexaLockController(self.entity),
|
||||
AlexaEndpointHealth(self.hass, self.entity),
|
||||
Alexa(self.entity),
|
||||
]
|
||||
yield AlexaLockController(self.entity)
|
||||
yield AlexaEndpointHealth(self.hass, self.entity)
|
||||
yield Alexa(self.entity)
|
||||
|
||||
|
||||
@ENTITY_ADAPTERS.register(media_player.const.DOMAIN)
|
||||
@@ -767,12 +772,10 @@ class SceneCapabilities(AlexaEntity):
|
||||
"""Return the display categories for this entity."""
|
||||
return [DisplayCategory.SCENE_TRIGGER]
|
||||
|
||||
def interfaces(self) -> list[AlexaCapability]:
|
||||
def interfaces(self) -> Generator[AlexaCapability, None, None]:
|
||||
"""Yield the supported interfaces."""
|
||||
return [
|
||||
AlexaSceneController(self.entity, supports_deactivation=False),
|
||||
Alexa(self.entity),
|
||||
]
|
||||
yield AlexaSceneController(self.entity, supports_deactivation=False)
|
||||
yield Alexa(self.entity)
|
||||
|
||||
|
||||
@ENTITY_ADAPTERS.register(script.DOMAIN)
|
||||
@@ -783,12 +786,10 @@ class ScriptCapabilities(AlexaEntity):
|
||||
"""Return the display categories for this entity."""
|
||||
return [DisplayCategory.ACTIVITY_TRIGGER]
|
||||
|
||||
def interfaces(self) -> list[AlexaCapability]:
|
||||
def interfaces(self) -> Generator[AlexaCapability, None, None]:
|
||||
"""Yield the supported interfaces."""
|
||||
return [
|
||||
AlexaSceneController(self.entity, supports_deactivation=True),
|
||||
Alexa(self.entity),
|
||||
]
|
||||
yield AlexaSceneController(self.entity, supports_deactivation=True)
|
||||
yield Alexa(self.entity)
|
||||
|
||||
|
||||
@ENTITY_ADAPTERS.register(sensor.DOMAIN)
|
||||
|
@@ -119,11 +119,18 @@ async def async_api_discovery(
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
discovery_endpoints = [
|
||||
alexa_entity.serialize_discovery()
|
||||
for alexa_entity in async_get_entities(hass, config)
|
||||
if config.should_expose(alexa_entity.entity_id)
|
||||
]
|
||||
discovery_endpoints: list[dict[str, Any]] = []
|
||||
for alexa_entity in async_get_entities(hass, config):
|
||||
if not config.should_expose(alexa_entity.entity_id):
|
||||
continue
|
||||
try:
|
||||
discovered_serialized_entity = alexa_entity.serialize_discovery()
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
_LOGGER.exception(
|
||||
"Unable to serialize %s for discovery: %s", alexa_entity.entity_id, exc
|
||||
)
|
||||
else:
|
||||
discovery_endpoints.append(discovered_serialized_entity)
|
||||
|
||||
return directive.response(
|
||||
name="Discover.Response",
|
||||
@@ -171,6 +178,8 @@ async def async_api_turn_on(
|
||||
service = SERVICE_TURN_ON
|
||||
if domain == cover.DOMAIN:
|
||||
service = cover.SERVICE_OPEN_COVER
|
||||
elif domain == climate.DOMAIN:
|
||||
service = climate.SERVICE_TURN_ON
|
||||
elif domain == fan.DOMAIN:
|
||||
service = fan.SERVICE_TURN_ON
|
||||
elif domain == humidifier.DOMAIN:
|
||||
@@ -220,6 +229,8 @@ async def async_api_turn_off(
|
||||
service = SERVICE_TURN_OFF
|
||||
if entity.domain == cover.DOMAIN:
|
||||
service = cover.SERVICE_CLOSE_COVER
|
||||
elif domain == climate.DOMAIN:
|
||||
service = climate.SERVICE_TURN_OFF
|
||||
elif domain == fan.DOMAIN:
|
||||
service = fan.SERVICE_TURN_OFF
|
||||
elif domain == humidifier.DOMAIN:
|
||||
|
@@ -74,9 +74,9 @@ def setup_platform(
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the Alpha Vantage sensor."""
|
||||
api_key = config[CONF_API_KEY]
|
||||
symbols = config.get(CONF_SYMBOLS, [])
|
||||
conversions = config.get(CONF_FOREIGN_EXCHANGE, [])
|
||||
api_key: str = config[CONF_API_KEY]
|
||||
symbols: list[dict[str, str]] = config.get(CONF_SYMBOLS, [])
|
||||
conversions: list[dict[str, str]] = config.get(CONF_FOREIGN_EXCHANGE, [])
|
||||
|
||||
if not symbols and not conversions:
|
||||
msg = "No symbols or currencies configured."
|
||||
@@ -120,7 +120,7 @@ class AlphaVantageSensor(SensorEntity):
|
||||
|
||||
_attr_attribution = ATTRIBUTION
|
||||
|
||||
def __init__(self, timeseries, symbol):
|
||||
def __init__(self, timeseries: TimeSeries, symbol: dict[str, str]) -> None:
|
||||
"""Initialize the sensor."""
|
||||
self._symbol = symbol[CONF_SYMBOL]
|
||||
self._attr_name = symbol.get(CONF_NAME, self._symbol)
|
||||
@@ -154,7 +154,9 @@ class AlphaVantageForeignExchange(SensorEntity):
|
||||
|
||||
_attr_attribution = ATTRIBUTION
|
||||
|
||||
def __init__(self, foreign_exchange, config):
|
||||
def __init__(
|
||||
self, foreign_exchange: ForeignExchange, config: dict[str, str]
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
self._foreign_exchange = foreign_exchange
|
||||
self._from_currency = config[CONF_FROM]
|
||||
|
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/amazon_polly",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["boto3", "botocore", "s3transfer"],
|
||||
"requirements": ["boto3==1.28.17"]
|
||||
"requirements": ["boto3==1.33.13"]
|
||||
}
|
||||
|
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
@@ -45,14 +44,14 @@ class AmberPriceGridSensor(
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the binary sensor is on."""
|
||||
return self.coordinator.data["grid"][self.entity_description.key]
|
||||
return self.coordinator.data["grid"][self.entity_description.key] # type: ignore[no-any-return]
|
||||
|
||||
|
||||
class AmberPriceSpikeBinarySensor(AmberPriceGridSensor):
|
||||
"""Sensor to show single grid binary values."""
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
def icon(self) -> str:
|
||||
"""Return the sensor icon."""
|
||||
status = self.coordinator.data["grid"]["price_spike"]
|
||||
return PRICE_SPIKE_ICONS[status]
|
||||
@@ -60,10 +59,10 @@ class AmberPriceSpikeBinarySensor(AmberPriceGridSensor):
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the binary sensor is on."""
|
||||
return self.coordinator.data["grid"]["price_spike"] == "spike"
|
||||
return self.coordinator.data["grid"]["price_spike"] == "spike" # type: ignore[no-any-return]
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> Mapping[str, Any] | None:
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return additional pieces of information about the price spike."""
|
||||
|
||||
spike_status = self.coordinator.data["grid"]["price_spike"]
|
||||
@@ -80,10 +79,10 @@ async def async_setup_entry(
|
||||
"""Set up a config entry."""
|
||||
coordinator: AmberUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
entities: list = []
|
||||
price_spike_description = BinarySensorEntityDescription(
|
||||
key="price_spike",
|
||||
name=f"{entry.title} - Price Spike",
|
||||
)
|
||||
entities.append(AmberPriceSpikeBinarySensor(coordinator, price_spike_description))
|
||||
async_add_entities(entities)
|
||||
async_add_entities(
|
||||
[AmberPriceSpikeBinarySensor(coordinator, price_spike_description)]
|
||||
)
|
||||
|
@@ -3,18 +3,46 @@ from __future__ import annotations
|
||||
|
||||
import amberelectric
|
||||
from amberelectric.api import amber_api
|
||||
from amberelectric.model.site import Site
|
||||
from amberelectric.model.site import Site, SiteStatus
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import CONF_API_TOKEN
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
)
|
||||
|
||||
from .const import CONF_SITE_ID, CONF_SITE_NAME, CONF_SITE_NMI, DOMAIN
|
||||
from .const import CONF_SITE_ID, CONF_SITE_NAME, DOMAIN
|
||||
|
||||
API_URL = "https://app.amber.com.au/developers"
|
||||
|
||||
|
||||
def generate_site_selector_name(site: Site) -> str:
|
||||
"""Generate the name to show in the site drop down in the configuration flow."""
|
||||
if site.status == SiteStatus.CLOSED:
|
||||
return site.nmi + " (Closed: " + site.closed_on.isoformat() + ")" # type: ignore[no-any-return]
|
||||
if site.status == SiteStatus.PENDING:
|
||||
return site.nmi + " (Pending)" # type: ignore[no-any-return]
|
||||
return site.nmi # type: ignore[no-any-return]
|
||||
|
||||
|
||||
def filter_sites(sites: list[Site]) -> list[Site]:
|
||||
"""Deduplicates the list of sites."""
|
||||
filtered: list[Site] = []
|
||||
filtered_nmi: set[str] = set()
|
||||
|
||||
for site in sorted(sites, key=lambda site: site.status.value):
|
||||
if site.status == SiteStatus.ACTIVE or site.nmi not in filtered_nmi:
|
||||
filtered.append(site)
|
||||
filtered_nmi.add(site.nmi)
|
||||
|
||||
return filtered
|
||||
|
||||
|
||||
class AmberElectricConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow."""
|
||||
|
||||
@@ -28,10 +56,10 @@ class AmberElectricConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
def _fetch_sites(self, token: str) -> list[Site] | None:
|
||||
configuration = amberelectric.Configuration(access_token=token)
|
||||
api = amber_api.AmberApi.create(configuration)
|
||||
api: amber_api.AmberApi = amber_api.AmberApi.create(configuration)
|
||||
|
||||
try:
|
||||
sites = api.get_sites()
|
||||
sites: list[Site] = filter_sites(api.get_sites())
|
||||
if len(sites) == 0:
|
||||
self._errors[CONF_API_TOKEN] = "no_site"
|
||||
return None
|
||||
@@ -86,38 +114,31 @@ class AmberElectricConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
assert self._sites is not None
|
||||
assert self._api_token is not None
|
||||
|
||||
api_token = self._api_token
|
||||
if user_input is not None:
|
||||
site_nmi = user_input[CONF_SITE_NMI]
|
||||
sites = [site for site in self._sites if site.nmi == site_nmi]
|
||||
site = sites[0]
|
||||
site_id = site.id
|
||||
site_id = user_input[CONF_SITE_ID]
|
||||
name = user_input.get(CONF_SITE_NAME, site_id)
|
||||
return self.async_create_entry(
|
||||
title=name,
|
||||
data={
|
||||
CONF_SITE_ID: site_id,
|
||||
CONF_API_TOKEN: api_token,
|
||||
CONF_SITE_NMI: site.nmi,
|
||||
},
|
||||
data={CONF_SITE_ID: site_id, CONF_API_TOKEN: self._api_token},
|
||||
)
|
||||
|
||||
user_input = {
|
||||
CONF_API_TOKEN: api_token,
|
||||
CONF_SITE_NMI: "",
|
||||
CONF_SITE_NAME: "",
|
||||
}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="site",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_SITE_NMI, default=user_input[CONF_SITE_NMI]
|
||||
): vol.In([site.nmi for site in self._sites]),
|
||||
vol.Optional(
|
||||
CONF_SITE_NAME, default=user_input[CONF_SITE_NAME]
|
||||
): str,
|
||||
vol.Required(CONF_SITE_ID): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
SelectOptionDict(
|
||||
value=site.id,
|
||||
label=generate_site_selector_name(site),
|
||||
)
|
||||
for site in self._sites
|
||||
],
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
vol.Optional(CONF_SITE_NAME): str,
|
||||
}
|
||||
),
|
||||
errors=self._errors,
|
||||
|
@@ -6,9 +6,8 @@ from homeassistant.const import Platform
|
||||
DOMAIN = "amberelectric"
|
||||
CONF_SITE_NAME = "site_name"
|
||||
CONF_SITE_ID = "site_id"
|
||||
CONF_SITE_NMI = "site_nmi"
|
||||
|
||||
ATTRIBUTION = "Data provided by Amber Electric"
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR]
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
|
||||
|
@@ -30,19 +30,19 @@ def is_forecast(interval: ActualInterval | CurrentInterval | ForecastInterval) -
|
||||
|
||||
def is_general(interval: ActualInterval | CurrentInterval | ForecastInterval) -> bool:
|
||||
"""Return true if the supplied interval is on the general channel."""
|
||||
return interval.channel_type == ChannelType.GENERAL
|
||||
return interval.channel_type == ChannelType.GENERAL # type: ignore[no-any-return]
|
||||
|
||||
|
||||
def is_controlled_load(
|
||||
interval: ActualInterval | CurrentInterval | ForecastInterval,
|
||||
) -> bool:
|
||||
"""Return true if the supplied interval is on the controlled load channel."""
|
||||
return interval.channel_type == ChannelType.CONTROLLED_LOAD
|
||||
return interval.channel_type == ChannelType.CONTROLLED_LOAD # type: ignore[no-any-return]
|
||||
|
||||
|
||||
def is_feed_in(interval: ActualInterval | CurrentInterval | ForecastInterval) -> bool:
|
||||
"""Return true if the supplied interval is on the feed in channel."""
|
||||
return interval.channel_type == ChannelType.FEED_IN
|
||||
return interval.channel_type == ChannelType.FEED_IN # type: ignore[no-any-return]
|
||||
|
||||
|
||||
def normalize_descriptor(descriptor: Descriptor) -> str | None:
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/amberelectric",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["amberelectric"],
|
||||
"requirements": ["amberelectric==1.0.4"]
|
||||
"requirements": ["amberelectric==1.1.0"]
|
||||
}
|
||||
|
@@ -7,7 +7,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from amberelectric.model.channel import ChannelType
|
||||
@@ -86,7 +85,7 @@ class AmberPriceSensor(AmberSensor):
|
||||
return format_cents_to_dollars(interval.per_kwh)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> Mapping[str, Any] | None:
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Return additional pieces of information about the price."""
|
||||
interval = self.coordinator.data[self.entity_description.key][self.channel_type]
|
||||
|
||||
@@ -133,7 +132,7 @@ class AmberForecastSensor(AmberSensor):
|
||||
return format_cents_to_dollars(interval.per_kwh)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> Mapping[str, Any] | None:
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Return additional pieces of information about the price."""
|
||||
intervals = self.coordinator.data[self.entity_description.key].get(
|
||||
self.channel_type
|
||||
@@ -177,7 +176,7 @@ class AmberPriceDescriptorSensor(AmberSensor):
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
"""Return the current price descriptor."""
|
||||
return self.coordinator.data[self.entity_description.key][self.channel_type]
|
||||
return self.coordinator.data[self.entity_description.key][self.channel_type] # type: ignore[no-any-return]
|
||||
|
||||
|
||||
class AmberGridSensor(CoordinatorEntity[AmberUpdateCoordinator], SensorEntity):
|
||||
@@ -199,7 +198,7 @@ class AmberGridSensor(CoordinatorEntity[AmberUpdateCoordinator], SensorEntity):
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
"""Return the value of the sensor."""
|
||||
return self.coordinator.data["grid"][self.entity_description.key]
|
||||
return self.coordinator.data["grid"][self.entity_description.key] # type: ignore[no-any-return]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -213,7 +212,7 @@ async def async_setup_entry(
|
||||
current: dict[str, CurrentInterval] = coordinator.data["current"]
|
||||
forecasts: dict[str, list[ForecastInterval]] = coordinator.data["forecasts"]
|
||||
|
||||
entities: list = []
|
||||
entities: list[SensorEntity] = []
|
||||
for channel_type in current:
|
||||
description = SensorEntityDescription(
|
||||
key="current",
|
||||
|
@@ -6,6 +6,7 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
import ambiclimate
|
||||
from ambiclimate import AmbiclimateDevice
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
@@ -152,18 +153,23 @@ class AmbiclimateEntity(ClimateEntity):
|
||||
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_target_temperature_step = 1
|
||||
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
)
|
||||
_attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF]
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
|
||||
def __init__(self, heater, store):
|
||||
def __init__(self, heater: AmbiclimateDevice, store: Store[dict[str, Any]]) -> None:
|
||||
"""Initialize the thermostat."""
|
||||
self._heater = heater
|
||||
self._store = store
|
||||
self._attr_unique_id = heater.device_id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self.unique_id)},
|
||||
identifiers={(DOMAIN, self.unique_id)}, # type: ignore[arg-type]
|
||||
manufacturer="Ambiclimate",
|
||||
name=heater.name,
|
||||
)
|
||||
|
@@ -114,7 +114,7 @@ class AmbiclimateFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
store = Store[dict[str, Any]](self.hass, STORAGE_VERSION, STORAGE_KEY)
|
||||
await store.async_save(token_info)
|
||||
|
||||
return token_info
|
||||
return token_info # type: ignore[no-any-return]
|
||||
|
||||
def _generate_view(self) -> None:
|
||||
self.hass.http.register_view(AmbiclimateAuthCallbackView())
|
||||
@@ -132,12 +132,12 @@ class AmbiclimateFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
clientsession,
|
||||
)
|
||||
|
||||
def _cb_url(self):
|
||||
def _cb_url(self) -> str:
|
||||
return f"{get_url(self.hass, prefer_external=True)}{AUTH_CALLBACK_PATH}"
|
||||
|
||||
async def _get_authorize_url(self):
|
||||
async def _get_authorize_url(self) -> str:
|
||||
oauth = self._generate_oauth()
|
||||
return oauth.get_authorize_url()
|
||||
return oauth.get_authorize_url() # type: ignore[no-any-return]
|
||||
|
||||
|
||||
class AmbiclimateAuthCallbackView(HomeAssistantView):
|
||||
|
@@ -63,20 +63,13 @@ TYPE_RELAY8 = "relay8"
|
||||
TYPE_RELAY9 = "relay9"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AmbientBinarySensorDescriptionMixin:
|
||||
"""Define an entity description mixin for binary sensors."""
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AmbientBinarySensorDescription(BinarySensorEntityDescription):
|
||||
"""Describe an Ambient PWS binary sensor."""
|
||||
|
||||
on_state: Literal[0, 1]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AmbientBinarySensorDescription(
|
||||
BinarySensorEntityDescription, AmbientBinarySensorDescriptionMixin
|
||||
):
|
||||
"""Describe an Ambient PWS binary sensor."""
|
||||
|
||||
|
||||
BINARY_SENSOR_DESCRIPTIONS = (
|
||||
AmbientBinarySensorDescription(
|
||||
key=TYPE_BATTOUT,
|
||||
|
27
homeassistant/components/ambient_station/icons.json
Normal file
27
homeassistant/components/ambient_station/icons.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"last_rain": {
|
||||
"default": "mdi:water"
|
||||
},
|
||||
"lightning_strikes_per_day": {
|
||||
"default": "mdi:lightning-bolt"
|
||||
},
|
||||
"lightning_strikes_per_hour": {
|
||||
"default": "mdi:lightning-bolt"
|
||||
},
|
||||
"wind_direction": {
|
||||
"default": "mdi:weather-windy"
|
||||
},
|
||||
"wind_direction_average_10m": {
|
||||
"default": "mdi:weather-windy"
|
||||
},
|
||||
"wind_direction_average_2m": {
|
||||
"default": "mdi:weather-windy"
|
||||
},
|
||||
"wind_gust_direction": {
|
||||
"default": "mdi:weather-windy"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aioambient"],
|
||||
"requirements": ["aioambient==2023.04.0"]
|
||||
"requirements": ["aioambient==2024.01.0"]
|
||||
}
|
||||
|
@@ -281,20 +281,17 @@ SENSOR_DESCRIPTIONS = (
|
||||
SensorEntityDescription(
|
||||
key=TYPE_LASTRAIN,
|
||||
translation_key="last_rain",
|
||||
icon="mdi:water",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_LIGHTNING_PER_DAY,
|
||||
translation_key="lightning_strikes_per_day",
|
||||
icon="mdi:lightning-bolt",
|
||||
native_unit_of_measurement="strikes",
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_LIGHTNING_PER_HOUR,
|
||||
translation_key="lightning_strikes_per_hour",
|
||||
icon="mdi:lightning-bolt",
|
||||
native_unit_of_measurement="strikes",
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
),
|
||||
@@ -595,25 +592,21 @@ SENSOR_DESCRIPTIONS = (
|
||||
SensorEntityDescription(
|
||||
key=TYPE_WINDDIR,
|
||||
translation_key="wind_direction",
|
||||
icon="mdi:weather-windy",
|
||||
native_unit_of_measurement=DEGREE,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_WINDDIR_AVG10M,
|
||||
translation_key="wind_direction_average_10m",
|
||||
icon="mdi:weather-windy",
|
||||
native_unit_of_measurement=DEGREE,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_WINDDIR_AVG2M,
|
||||
translation_key="wind_direction_average_2m",
|
||||
icon="mdi:weather-windy",
|
||||
native_unit_of_measurement=DEGREE,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_WINDGUSTDIR,
|
||||
translation_key="wind_gust_direction",
|
||||
icon="mdi:weather-windy",
|
||||
native_unit_of_measurement=DEGREE,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
|
71
homeassistant/components/analytics_insights/__init__.py
Normal file
71
homeassistant/components/analytics_insights/__init__.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""The Homeassistant Analytics integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from python_homeassistant_analytics import (
|
||||
HomeassistantAnalyticsClient,
|
||||
HomeassistantAnalyticsConnectionError,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import CONF_TRACKED_INTEGRATIONS, DOMAIN
|
||||
from .coordinator import HomeassistantAnalyticsDataUpdateCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AnalyticsInsightsData:
|
||||
"""Analytics data class."""
|
||||
|
||||
coordinator: HomeassistantAnalyticsDataUpdateCoordinator
|
||||
names: dict[str, str]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Homeassistant Analytics from a config entry."""
|
||||
client = HomeassistantAnalyticsClient(session=async_get_clientsession(hass))
|
||||
|
||||
try:
|
||||
integrations = await client.get_integrations()
|
||||
except HomeassistantAnalyticsConnectionError as ex:
|
||||
raise ConfigEntryNotReady("Could not fetch integration list") from ex
|
||||
|
||||
names = {}
|
||||
for integration in entry.options[CONF_TRACKED_INTEGRATIONS]:
|
||||
if integration not in integrations:
|
||||
names[integration] = integration
|
||||
continue
|
||||
names[integration] = integrations[integration].title
|
||||
|
||||
coordinator = HomeassistantAnalyticsDataUpdateCoordinator(hass, client)
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = AnalyticsInsightsData(
|
||||
coordinator=coordinator, names=names
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
entry.async_on_unload(entry.add_update_listener(update_listener))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
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):
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Handle options update."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
186
homeassistant/components/analytics_insights/config_flow.py
Normal file
186
homeassistant/components/analytics_insights/config_flow.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""Config flow for Homeassistant Analytics integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from python_homeassistant_analytics import (
|
||||
HomeassistantAnalyticsClient,
|
||||
HomeassistantAnalyticsConnectionError,
|
||||
)
|
||||
from python_homeassistant_analytics.models import IntegrationType
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
OptionsFlow,
|
||||
OptionsFlowWithConfigEntry,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
CONF_TRACKED_CUSTOM_INTEGRATIONS,
|
||||
CONF_TRACKED_INTEGRATIONS,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
)
|
||||
|
||||
INTEGRATION_TYPES_WITHOUT_ANALYTICS = (
|
||||
IntegrationType.BRAND,
|
||||
IntegrationType.ENTITY,
|
||||
IntegrationType.VIRTUAL,
|
||||
)
|
||||
|
||||
|
||||
class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Homeassistant Analytics."""
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
|
||||
"""Get the options flow for this handler."""
|
||||
return HomeassistantAnalyticsOptionsFlowHandler(config_entry)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle the initial step."""
|
||||
self._async_abort_entries_match()
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
if not user_input.get(CONF_TRACKED_INTEGRATIONS) and not user_input.get(
|
||||
CONF_TRACKED_CUSTOM_INTEGRATIONS
|
||||
):
|
||||
errors["base"] = "no_integrations_selected"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title="Home Assistant Analytics Insights",
|
||||
data={},
|
||||
options={
|
||||
CONF_TRACKED_INTEGRATIONS: user_input.get(
|
||||
CONF_TRACKED_INTEGRATIONS, []
|
||||
),
|
||||
CONF_TRACKED_CUSTOM_INTEGRATIONS: user_input.get(
|
||||
CONF_TRACKED_CUSTOM_INTEGRATIONS, []
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
client = HomeassistantAnalyticsClient(
|
||||
session=async_get_clientsession(self.hass)
|
||||
)
|
||||
try:
|
||||
integrations = await client.get_integrations()
|
||||
custom_integrations = await client.get_custom_integrations()
|
||||
except HomeassistantAnalyticsConnectionError:
|
||||
LOGGER.exception("Error connecting to Home Assistant analytics")
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
options = [
|
||||
SelectOptionDict(
|
||||
value=domain,
|
||||
label=integration.title,
|
||||
)
|
||||
for domain, integration in integrations.items()
|
||||
if integration.integration_type not in INTEGRATION_TYPES_WITHOUT_ANALYTICS
|
||||
]
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
errors=errors,
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_TRACKED_INTEGRATIONS): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=options,
|
||||
multiple=True,
|
||||
sort=True,
|
||||
)
|
||||
),
|
||||
vol.Optional(CONF_TRACKED_CUSTOM_INTEGRATIONS): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=list(custom_integrations),
|
||||
multiple=True,
|
||||
sort=True,
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithConfigEntry):
|
||||
"""Handle Homeassistant Analytics options."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Manage the options."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
if not user_input.get(CONF_TRACKED_INTEGRATIONS) and not user_input.get(
|
||||
CONF_TRACKED_CUSTOM_INTEGRATIONS
|
||||
):
|
||||
errors["base"] = "no_integrations_selected"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title="",
|
||||
data={
|
||||
CONF_TRACKED_INTEGRATIONS: user_input.get(
|
||||
CONF_TRACKED_INTEGRATIONS, []
|
||||
),
|
||||
CONF_TRACKED_CUSTOM_INTEGRATIONS: user_input.get(
|
||||
CONF_TRACKED_CUSTOM_INTEGRATIONS, []
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
client = HomeassistantAnalyticsClient(
|
||||
session=async_get_clientsession(self.hass)
|
||||
)
|
||||
try:
|
||||
integrations = await client.get_integrations()
|
||||
custom_integrations = await client.get_custom_integrations()
|
||||
except HomeassistantAnalyticsConnectionError:
|
||||
LOGGER.exception("Error connecting to Home Assistant analytics")
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
options = [
|
||||
SelectOptionDict(
|
||||
value=domain,
|
||||
label=integration.title,
|
||||
)
|
||||
for domain, integration in integrations.items()
|
||||
if integration.integration_type not in INTEGRATION_TYPES_WITHOUT_ANALYTICS
|
||||
]
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
errors=errors,
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_TRACKED_INTEGRATIONS): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=options,
|
||||
multiple=True,
|
||||
sort=True,
|
||||
)
|
||||
),
|
||||
vol.Optional(CONF_TRACKED_CUSTOM_INTEGRATIONS): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=list(custom_integrations),
|
||||
multiple=True,
|
||||
sort=True,
|
||||
)
|
||||
),
|
||||
},
|
||||
),
|
||||
self.options,
|
||||
),
|
||||
)
|
9
homeassistant/components/analytics_insights/const.py
Normal file
9
homeassistant/components/analytics_insights/const.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""Constants for the Homeassistant Analytics integration."""
|
||||
import logging
|
||||
|
||||
DOMAIN = "analytics_insights"
|
||||
|
||||
CONF_TRACKED_INTEGRATIONS = "tracked_integrations"
|
||||
CONF_TRACKED_CUSTOM_INTEGRATIONS = "tracked_custom_integrations"
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
84
homeassistant/components/analytics_insights/coordinator.py
Normal file
84
homeassistant/components/analytics_insights/coordinator.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""DataUpdateCoordinator for the Homeassistant Analytics integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
from python_homeassistant_analytics import (
|
||||
CustomIntegration,
|
||||
HomeassistantAnalyticsClient,
|
||||
HomeassistantAnalyticsConnectionError,
|
||||
HomeassistantAnalyticsNotModifiedError,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import (
|
||||
CONF_TRACKED_CUSTOM_INTEGRATIONS,
|
||||
CONF_TRACKED_INTEGRATIONS,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AnalyticsData:
|
||||
"""Analytics data class."""
|
||||
|
||||
core_integrations: dict[str, int]
|
||||
custom_integrations: dict[str, int]
|
||||
|
||||
|
||||
class HomeassistantAnalyticsDataUpdateCoordinator(DataUpdateCoordinator[AnalyticsData]):
|
||||
"""A Homeassistant Analytics Data Update Coordinator."""
|
||||
|
||||
config_entry: ConfigEntry
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, client: HomeassistantAnalyticsClient
|
||||
) -> None:
|
||||
"""Initialize the Homeassistant Analytics data coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(hours=12),
|
||||
)
|
||||
self._client = client
|
||||
self._tracked_integrations = self.config_entry.options[
|
||||
CONF_TRACKED_INTEGRATIONS
|
||||
]
|
||||
self._tracked_custom_integrations = self.config_entry.options[
|
||||
CONF_TRACKED_CUSTOM_INTEGRATIONS
|
||||
]
|
||||
|
||||
async def _async_update_data(self) -> AnalyticsData:
|
||||
try:
|
||||
data = await self._client.get_current_analytics()
|
||||
custom_data = await self._client.get_custom_integrations()
|
||||
except HomeassistantAnalyticsConnectionError as err:
|
||||
raise UpdateFailed(
|
||||
"Error communicating with Homeassistant Analytics"
|
||||
) from err
|
||||
except HomeassistantAnalyticsNotModifiedError:
|
||||
return self.data
|
||||
core_integrations = {
|
||||
integration: data.integrations.get(integration, 0)
|
||||
for integration in self._tracked_integrations
|
||||
}
|
||||
custom_integrations = {
|
||||
integration: get_custom_integration_value(custom_data, integration)
|
||||
for integration in self._tracked_custom_integrations
|
||||
}
|
||||
return AnalyticsData(core_integrations, custom_integrations)
|
||||
|
||||
|
||||
def get_custom_integration_value(
|
||||
data: dict[str, CustomIntegration], domain: str
|
||||
) -> int:
|
||||
"""Get custom integration value."""
|
||||
if domain in data:
|
||||
return data[domain].total
|
||||
return 0
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user