This commit is contained in:
Bram Kragten 2023-09-06 17:16:35 +02:00 committed by GitHub
commit f70469d6ef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2706 changed files with 85482 additions and 24628 deletions

View File

@ -30,6 +30,7 @@ base_platforms: &base_platforms
- homeassistant/components/humidifier/**
- homeassistant/components/image/**
- homeassistant/components/image_processing/**
- homeassistant/components/lawn_mower/**
- homeassistant/components/light/**
- homeassistant/components/lock/**
- homeassistant/components/media_player/**
@ -54,6 +55,7 @@ base_platforms: &base_platforms
components: &components
- homeassistant/components/alexa/**
- homeassistant/components/application_credentials/**
- homeassistant/components/assist_pipeline/**
- homeassistant/components/auth/**
- homeassistant/components/automation/**
- homeassistant/components/backup/**
@ -86,6 +88,7 @@ components: &components
- homeassistant/components/lovelace/**
- homeassistant/components/media_source/**
- homeassistant/components/mjpeg/**
- homeassistant/components/modbus/**
- homeassistant/components/mqtt/**
- homeassistant/components/network/**
- homeassistant/components/onboarding/**

View File

@ -57,6 +57,7 @@ omit =
homeassistant/components/ambiclimate/climate.py
homeassistant/components/ambient_station/__init__.py
homeassistant/components/ambient_station/binary_sensor.py
homeassistant/components/ambient_station/entity.py
homeassistant/components/ambient_station/sensor.py
homeassistant/components/amcrest/*
homeassistant/components/ampio/*
@ -168,6 +169,10 @@ omit =
homeassistant/components/cmus/media_player.py
homeassistant/components/coinbase/sensor.py
homeassistant/components/comed_hourly_pricing/sensor.py
homeassistant/components/comelit/__init__.py
homeassistant/components/comelit/const.py
homeassistant/components/comelit/coordinator.py
homeassistant/components/comelit/light.py
homeassistant/components/comfoconnect/fan.py
homeassistant/components/concord232/alarm_control_panel.py
homeassistant/components/concord232/binary_sensor.py
@ -212,10 +217,12 @@ omit =
homeassistant/components/dominos/*
homeassistant/components/doods/*
homeassistant/components/doorbird/__init__.py
homeassistant/components/doorbird/button.py
homeassistant/components/doorbird/camera.py
homeassistant/components/doorbird/button.py
homeassistant/components/doorbird/device.py
homeassistant/components/doorbird/entity.py
homeassistant/components/doorbird/util.py
homeassistant/components/doorbird/view.py
homeassistant/components/dormakaba_dkey/__init__.py
homeassistant/components/dormakaba_dkey/binary_sensor.py
homeassistant/components/dormakaba_dkey/entity.py
@ -234,6 +241,7 @@ omit =
homeassistant/components/duotecno/entity.py
homeassistant/components/duotecno/switch.py
homeassistant/components/duotecno/cover.py
homeassistant/components/duotecno/light.py
homeassistant/components/dwd_weather_warnings/const.py
homeassistant/components/dwd_weather_warnings/coordinator.py
homeassistant/components/dwd_weather_warnings/sensor.py
@ -270,6 +278,7 @@ omit =
homeassistant/components/electric_kiwi/oauth2.py
homeassistant/components/electric_kiwi/sensor.py
homeassistant/components/electric_kiwi/coordinator.py
homeassistant/components/electric_kiwi/select.py
homeassistant/components/eliqonline/sensor.py
homeassistant/components/elkm1/__init__.py
homeassistant/components/elkm1/alarm_control_panel.py
@ -282,7 +291,8 @@ omit =
homeassistant/components/elmax/alarm_control_panel.py
homeassistant/components/elmax/binary_sensor.py
homeassistant/components/elmax/common.py
homeassistant/components/elmax/binary_sensor.py
homeassistant/components/elmax/const.py
homeassistant/components/elmax/cover.py
homeassistant/components/elmax/switch.py
homeassistant/components/elv/*
homeassistant/components/emby/media_player.py
@ -299,7 +309,13 @@ omit =
homeassistant/components/enocean/sensor.py
homeassistant/components/enocean/switch.py
homeassistant/components/enphase_envoy/__init__.py
homeassistant/components/enphase_envoy/binary_sensor.py
homeassistant/components/enphase_envoy/coordinator.py
homeassistant/components/enphase_envoy/entity.py
homeassistant/components/enphase_envoy/number.py
homeassistant/components/enphase_envoy/select.py
homeassistant/components/enphase_envoy/sensor.py
homeassistant/components/enphase_envoy/switch.py
homeassistant/components/entur_public_transport/*
homeassistant/components/environment_canada/__init__.py
homeassistant/components/environment_canada/camera.py
@ -334,6 +350,7 @@ omit =
homeassistant/components/ezviz/entity.py
homeassistant/components/ezviz/select.py
homeassistant/components/ezviz/sensor.py
homeassistant/components/ezviz/siren.py
homeassistant/components/ezviz/switch.py
homeassistant/components/ezviz/update.py
homeassistant/components/faa_delays/__init__.py
@ -415,6 +432,7 @@ omit =
homeassistant/components/garadget/cover.py
homeassistant/components/garages_amsterdam/__init__.py
homeassistant/components/garages_amsterdam/binary_sensor.py
homeassistant/components/garages_amsterdam/entity.py
homeassistant/components/garages_amsterdam/sensor.py
homeassistant/components/gc100/*
homeassistant/components/geniushub/*
@ -708,8 +726,6 @@ omit =
homeassistant/components/meteoclimatic/__init__.py
homeassistant/components/meteoclimatic/sensor.py
homeassistant/components/meteoclimatic/weather.py
homeassistant/components/metoffice/sensor.py
homeassistant/components/metoffice/weather.py
homeassistant/components/microsoft/tts.py
homeassistant/components/mikrotik/hub.py
homeassistant/components/mill/climate.py
@ -764,10 +780,12 @@ omit =
homeassistant/components/neato/__init__.py
homeassistant/components/neato/api.py
homeassistant/components/neato/camera.py
homeassistant/components/neato/entity.py
homeassistant/components/neato/hub.py
homeassistant/components/neato/sensor.py
homeassistant/components/neato/switch.py
homeassistant/components/neato/vacuum.py
homeassistant/components/neato/button.py
homeassistant/components/nederlandse_spoorwegen/sensor.py
homeassistant/components/netdata/sensor.py
homeassistant/components/netgear/__init__.py
@ -859,7 +877,6 @@ omit =
homeassistant/components/openhome/const.py
homeassistant/components/openhome/media_player.py
homeassistant/components/opensensemap/air_quality.py
homeassistant/components/opensky/sensor.py
homeassistant/components/opentherm_gw/__init__.py
homeassistant/components/opentherm_gw/binary_sensor.py
homeassistant/components/opentherm_gw/climate.py
@ -989,6 +1006,7 @@ omit =
homeassistant/components/renson/const.py
homeassistant/components/renson/entity.py
homeassistant/components/renson/sensor.py
homeassistant/components/renson/binary_sensor.py
homeassistant/components/raspyrfm/*
homeassistant/components/recollect_waste/sensor.py
homeassistant/components/recorder/repack.py
@ -1167,7 +1185,13 @@ omit =
homeassistant/components/squeezebox/__init__.py
homeassistant/components/squeezebox/browse_media.py
homeassistant/components/squeezebox/media_player.py
homeassistant/components/starlink/__init__.py
homeassistant/components/starlink/binary_sensor.py
homeassistant/components/starlink/button.py
homeassistant/components/starlink/coordinator.py
homeassistant/components/starlink/device_tracker.py
homeassistant/components/starlink/sensor.py
homeassistant/components/starlink/switch.py
homeassistant/components/starline/__init__.py
homeassistant/components/starline/account.py
homeassistant/components/starline/binary_sensor.py
@ -1311,9 +1335,6 @@ omit =
homeassistant/components/tplink_omada/__init__.py
homeassistant/components/tplink_omada/binary_sensor.py
homeassistant/components/tplink_omada/controller.py
homeassistant/components/tplink_omada/coordinator.py
homeassistant/components/tplink_omada/entity.py
homeassistant/components/tplink_omada/switch.py
homeassistant/components/tplink_omada/update.py
homeassistant/components/traccar/device_tracker.py
homeassistant/components/tractive/__init__.py
@ -1333,9 +1354,11 @@ omit =
homeassistant/components/trafikverket_train/__init__.py
homeassistant/components/trafikverket_train/coordinator.py
homeassistant/components/trafikverket_train/sensor.py
homeassistant/components/trafikverket_train/util.py
homeassistant/components/trafikverket_weatherstation/__init__.py
homeassistant/components/trafikverket_weatherstation/coordinator.py
homeassistant/components/trafikverket_weatherstation/sensor.py
homeassistant/components/transmission/__init__.py
homeassistant/components/transmission/sensor.py
homeassistant/components/transmission/switch.py
homeassistant/components/travisci/sensor.py
@ -1418,6 +1441,10 @@ omit =
homeassistant/components/vlc/media_player.py
homeassistant/components/vlc_telnet/__init__.py
homeassistant/components/vlc_telnet/media_player.py
homeassistant/components/vodafone_station/__init__.py
homeassistant/components/vodafone_station/const.py
homeassistant/components/vodafone_station/coordinator.py
homeassistant/components/vodafone_station/device_tracker.py
homeassistant/components/volkszaehler/sensor.py
homeassistant/components/volumio/__init__.py
homeassistant/components/volumio/browse_media.py
@ -1487,7 +1514,6 @@ omit =
homeassistant/components/yale_smart_alarm/alarm_control_panel.py
homeassistant/components/yale_smart_alarm/binary_sensor.py
homeassistant/components/yale_smart_alarm/button.py
homeassistant/components/yale_smart_alarm/coordinator.py
homeassistant/components/yale_smart_alarm/entity.py
homeassistant/components/yale_smart_alarm/lock.py
homeassistant/components/yalexs_ble/__init__.py
@ -1502,6 +1528,9 @@ omit =
homeassistant/components/yamaha_musiccast/select.py
homeassistant/components/yamaha_musiccast/switch.py
homeassistant/components/yandex_transport/sensor.py
homeassistant/components/yardian/__init__.py
homeassistant/components/yardian/coordinator.py
homeassistant/components/yardian/switch.py
homeassistant/components/yeelightsunflower/light.py
homeassistant/components/yi/camera.py
homeassistant/components/yolink/__init__.py

View File

@ -24,7 +24,7 @@ jobs:
publish: ${{ steps.version.outputs.publish }}
steps:
- name: Checkout the repository
uses: actions/checkout@v3.5.3
uses: actions/checkout@v3.6.0
with:
fetch-depth: 0
@ -56,7 +56,7 @@ jobs:
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
steps:
- name: Checkout the repository
uses: actions/checkout@v3.5.3
uses: actions/checkout@v3.6.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.7.0
@ -98,7 +98,7 @@ jobs:
arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps:
- name: Checkout the repository
uses: actions/checkout@v3.5.3
uses: actions/checkout@v3.6.0
- name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev'
@ -197,7 +197,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build base image
uses: home-assistant/builder@2023.06.1
uses: home-assistant/builder@2023.08.0
with:
args: |
$BUILD_ARGS \
@ -251,9 +251,10 @@ jobs:
- raspberrypi4-64
- tinker
- yellow
- green
steps:
- name: Checkout the repository
uses: actions/checkout@v3.5.3
uses: actions/checkout@v3.6.0
- name: Set build additional args
run: |
@ -274,7 +275,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build base image
uses: home-assistant/builder@2023.06.1
uses: home-assistant/builder@2023.08.0
with:
args: |
$BUILD_ARGS \
@ -292,7 +293,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@v3.5.3
uses: actions/checkout@v3.6.0
- name: Initialize git
uses: home-assistant/actions/helpers/git-init@master
@ -330,7 +331,7 @@ jobs:
id-token: write
steps:
- name: Checkout the repository
uses: actions/checkout@v3.5.3
uses: actions/checkout@v3.6.0
- name: Install Cosign
uses: sigstore/cosign-installer@v3.1.1

View File

@ -19,6 +19,10 @@ on:
description: "Skip pytest"
default: false
type: boolean
skip-coverage:
description: "Skip coverage"
default: false
type: boolean
pylint-only:
description: "Only run pylint"
default: false
@ -32,7 +36,7 @@ env:
CACHE_VERSION: 5
PIP_CACHE_VERSION: 4
MYPY_CACHE_VERSION: 4
HA_SHORT_VERSION: 2023.8
HA_SHORT_VERSION: 2023.9
DEFAULT_PYTHON: "3.11"
ALL_PYTHON_VERSIONS: "['3.11']"
# 10.3 is the oldest supported version
@ -79,10 +83,11 @@ jobs:
test_groups: ${{ steps.info.outputs.test_groups }}
tests_glob: ${{ steps.info.outputs.tests_glob }}
tests: ${{ steps.info.outputs.tests }}
skip_coverage: ${{ steps.info.outputs.skip_coverage }}
runs-on: ubuntu-22.04
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.3
uses: actions/checkout@v3.6.0
- name: Generate partial Python venv restore key
id: generate_python_cache_key
run: >-
@ -127,6 +132,7 @@ jobs:
test_group_count=10
tests="[]"
tests_glob=""
skip_coverage=""
if [[ "${{ steps.integrations.outputs.changes }}" != "[]" ]];
then
@ -176,6 +182,12 @@ jobs:
test_full_suite="true"
fi
if [[ "${{ github.event.inputs.skip-coverage }}" == "true" ]] \
|| [[ "${{ contains(github.event.pull_request.labels.*.name, 'ci-skip-coverage') }}" == "true" ]];
then
skip_coverage="true"
fi
# Output & sent to GitHub Actions
echo "mariadb_groups: ${mariadb_groups}"
echo "mariadb_groups=${mariadb_groups}" >> $GITHUB_OUTPUT
@ -195,6 +207,8 @@ jobs:
echo "tests=${tests}" >> $GITHUB_OUTPUT
echo "tests_glob: ${tests_glob}"
echo "tests_glob=${tests_glob}" >> $GITHUB_OUTPUT
echo "skip_coverage: ${skip_coverage}"
echo "skip_coverage=${skip_coverage}" >> $GITHUB_OUTPUT
pre-commit:
name: Prepare pre-commit base
@ -206,7 +220,7 @@ jobs:
- info
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.3
uses: actions/checkout@v3.6.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v4.7.0
@ -251,7 +265,7 @@ jobs:
- pre-commit
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.3
uses: actions/checkout@v3.6.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.7.0
id: python
@ -297,7 +311,7 @@ jobs:
- pre-commit
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.3
uses: actions/checkout@v3.6.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.7.0
id: python
@ -346,7 +360,7 @@ jobs:
- pre-commit
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.3
uses: actions/checkout@v3.6.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.7.0
id: python
@ -440,7 +454,7 @@ jobs:
python-version: ${{ fromJSON(needs.info.outputs.python_versions) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.3
uses: actions/checkout@v3.6.0
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v4.7.0
@ -508,7 +522,7 @@ jobs:
- base
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.3
uses: actions/checkout@v3.6.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v4.7.0
@ -540,7 +554,7 @@ jobs:
- base
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.3
uses: actions/checkout@v3.6.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v4.7.0
@ -573,7 +587,7 @@ jobs:
- base
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.3
uses: actions/checkout@v3.6.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v4.7.0
@ -617,7 +631,7 @@ jobs:
- base
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.3
uses: actions/checkout@v3.6.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v4.7.0
@ -699,7 +713,7 @@ jobs:
bluez \
ffmpeg
- name: Check out code from GitHub
uses: actions/checkout@v3.5.3
uses: actions/checkout@v3.6.0
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v4.7.0
@ -734,9 +748,19 @@ jobs:
- name: Run pytest (fully)
if: needs.info.outputs.test_full_suite == 'true'
timeout-minutes: 60
id: pytest-full
env:
PYTHONDONTWRITEBYTECODE: 1
run: |
. venv/bin/activate
python --version
set -o pipefail
cov_params=()
if [[ "${{ needs.info.outputs.skip_coverage }}" != "true" ]]; then
cov_params+=(--cov="homeassistant")
cov_params+=(--cov-report=xml)
fi
python3 -X dev -m pytest \
-qq \
--timeout=9 \
@ -745,37 +769,54 @@ jobs:
--dist=loadfile \
--test-group-count ${{ needs.info.outputs.test_group_count }} \
--test-group=${{ matrix.group }} \
--cov="homeassistant" \
--cov-report=xml \
${cov_params[@]} \
-o console_output_style=count \
-p no:sugar \
tests
tests \
2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt
- name: Run pytest (partially)
if: needs.info.outputs.test_full_suite == 'false'
timeout-minutes: 10
id: pytest-partial
shell: bash
env:
PYTHONDONTWRITEBYTECODE: 1
run: |
. venv/bin/activate
python --version
set -o pipefail
if [[ ! -f "tests/components/${{ matrix.group }}/__init__.py" ]]; then
echo "::error:: missing file tests/components/${{ matrix.group }}/__init__.py"
exit 1
fi
cov_params=()
if [[ "${{ needs.info.outputs.skip_coverage }}" != "true" ]]; then
cov_params+=(--cov="homeassistant.components.${{ matrix.group }}")
cov_params+=(--cov-report=xml)
cov_params+=(--cov-report=term-missing)
fi
python3 -X dev -m pytest \
-qq \
--timeout=9 \
-n auto \
--cov="homeassistant.components.${{ matrix.group }}" \
--cov-report=xml \
--cov-report=term-missing \
${cov_params[@]} \
-o console_output_style=count \
--durations=0 \
--durations-min=1 \
-p no:sugar \
tests/components/${{ matrix.group }}
tests/components/${{ matrix.group }} \
2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt
- name: Upload pytest output
if: success() || failure() && (steps.pytest-full.conclusion == 'failure' || steps.pytest-partial.conclusion == 'failure')
uses: actions/upload-artifact@v3.1.2
with:
name: pytest-${{ github.run_number }}
path: pytest-*.txt
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
uses: actions/upload-artifact@v3.1.2
with:
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
@ -824,7 +865,7 @@ jobs:
ffmpeg \
libmariadb-dev-compat
- name: Check out code from GitHub
uses: actions/checkout@v3.5.3
uses: actions/checkout@v3.6.0
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v4.7.0
@ -862,18 +903,27 @@ jobs:
python3 -m script.translations develop --all
- name: Run pytest (partially)
timeout-minutes: 20
id: pytest-partial
shell: bash
env:
PYTHONDONTWRITEBYTECODE: 1
run: |
. venv/bin/activate
python --version
set -o pipefail
mariadb=$(echo "${{ matrix.mariadb-group }}" | sed "s/:/-/g")
cov_params=()
if [[ "${{ needs.info.outputs.skip_coverage }}" != "true" ]]; then
cov_params+=(--cov="homeassistant.components.recorder")
cov_params+=(--cov-report=xml)
cov_params+=(--cov-report=term-missing)
fi
python3 -X dev -m pytest \
-qq \
--timeout=20 \
-n 1 \
--cov="homeassistant.components.recorder" \
--cov-report=xml \
--cov-report=term-missing \
${cov_params[@]} \
-o console_output_style=count \
--durations=10 \
-p no:sugar \
@ -881,8 +931,16 @@ jobs:
tests/components/history \
tests/components/logbook \
tests/components/recorder \
tests/components/sensor
tests/components/sensor \
2>&1 | tee pytest-${{ matrix.python-version }}-${mariadb}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
uses: actions/upload-artifact@v3.1.2
with:
name: pytest-${{ github.run_number }}
path: pytest-*.txt
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
uses: actions/upload-artifact@v3.1.2
with:
name: coverage-${{ matrix.python-version }}-mariadb
@ -931,7 +989,7 @@ jobs:
ffmpeg \
postgresql-server-dev-14
- name: Check out code from GitHub
uses: actions/checkout@v3.5.3
uses: actions/checkout@v3.6.0
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v4.7.0
@ -969,18 +1027,27 @@ jobs:
python3 -m script.translations develop --all
- name: Run pytest (partially)
timeout-minutes: 20
id: pytest-partial
shell: bash
env:
PYTHONDONTWRITEBYTECODE: 1
run: |
. venv/bin/activate
python --version
set -o pipefail
postgresql=$(echo "${{ matrix.postgresql-group }}" | sed "s/:/-/g")
cov_params=()
if [[ "${{ needs.info.outputs.skip_coverage }}" != "true" ]]; then
cov_params+=(--cov="homeassistant.components.recorder")
cov_params+=(--cov-report=xml)
cov_params+=(--cov-report=term-missing)
fi
python3 -X dev -m pytest \
-qq \
--timeout=9 \
-n 1 \
--cov="homeassistant.components.recorder" \
--cov-report=xml \
--cov-report=term-missing \
${cov_params[@]} \
-o console_output_style=count \
--durations=0 \
--durations-min=10 \
@ -989,8 +1056,16 @@ jobs:
tests/components/history \
tests/components/logbook \
tests/components/recorder \
tests/components/sensor
tests/components/sensor \
2>&1 | tee pytest-${{ matrix.python-version }}-${postgresql}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
uses: actions/upload-artifact@v3.1.2
with:
name: pytest-${{ github.run_number }}
path: pytest-*.txt
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
uses: actions/upload-artifact@v3.1.0
with:
name: coverage-${{ matrix.python-version }}-postgresql
@ -1001,6 +1076,7 @@ jobs:
coverage:
name: Upload test coverage to Codecov
if: needs.info.outputs.skip_coverage != 'true'
runs-on: ubuntu-22.04
needs:
- info
@ -1008,7 +1084,7 @@ jobs:
timeout-minutes: 10
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.3
uses: actions/checkout@v3.6.0
- name: Download all coverage artifacts
uses: actions/download-artifact@v3
- name: Upload coverage to Codecov (full coverage)

View File

@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@v3.5.3
uses: actions/checkout@v3.6.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.7.0

View File

@ -26,7 +26,7 @@ jobs:
architectures: ${{ steps.info.outputs.architectures }}
steps:
- name: Checkout the repository
uses: actions/checkout@v3.5.3
uses: actions/checkout@v3.6.0
- name: Get information
id: info
@ -84,7 +84,7 @@ jobs:
arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps:
- name: Checkout the repository
uses: actions/checkout@v3.5.3
uses: actions/checkout@v3.6.0
- name: Download env_file
uses: actions/download-artifact@v3
@ -122,7 +122,7 @@ jobs:
arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps:
- name: Checkout the repository
uses: actions/checkout@v3.5.3
uses: actions/checkout@v3.6.0
- name: Download env_file
uses: actions/download-artifact@v3

1
.gitignore vendored
View File

@ -67,6 +67,7 @@ htmlcov/
test-reports/
test-results.xml
test-output.xml
pytest-*.txt
# Translations
*.mo

View File

@ -1,11 +1,11 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.0.280
rev: v0.0.285
hooks:
- id: ruff
args:
- --fix
- repo: https://github.com/psf/black
- repo: https://github.com/psf/black-pre-commit-mirror
rev: 23.7.0
hooks:
- id: black
@ -43,7 +43,7 @@ repos:
hooks:
- id: prettier
- repo: https://github.com/cdce8p/python-typing-update
rev: v0.5.0
rev: v0.6.0
hooks:
# Run `python-typing-update` hook manually from time to time
# to update python typing syntax.
@ -52,7 +52,7 @@ repos:
- id: python-typing-update
stages: [manual]
args:
- --py310-plus
- --py311-plus
- --force
- --keep-updates
files: ^(homeassistant|tests|script)/.+\.py$

View File

@ -53,6 +53,7 @@ homeassistant.components.airzone_cloud.*
homeassistant.components.aladdin_connect.*
homeassistant.components.alarm_control_panel.*
homeassistant.components.alert.*
homeassistant.components.alexa.*
homeassistant.components.amazon_polly.*
homeassistant.components.ambient_station.*
homeassistant.components.amcrest.*
@ -103,6 +104,7 @@ homeassistant.components.dhcp.*
homeassistant.components.diagnostics.*
homeassistant.components.dlna_dmr.*
homeassistant.components.dnsip.*
homeassistant.components.doorbird.*
homeassistant.components.dormakaba_dkey.*
homeassistant.components.dsmr.*
homeassistant.components.dunehd.*
@ -147,6 +149,7 @@ homeassistant.components.history.*
homeassistant.components.homeassistant.exposed_entities
homeassistant.components.homeassistant.triggers.event
homeassistant.components.homeassistant_alerts.*
homeassistant.components.homeassistant_green.*
homeassistant.components.homeassistant_hardware.*
homeassistant.components.homeassistant_sky_connect.*
homeassistant.components.homeassistant_yellow.*
@ -181,6 +184,7 @@ homeassistant.components.imap.*
homeassistant.components.input_button.*
homeassistant.components.input_select.*
homeassistant.components.integration.*
homeassistant.components.ipp.*
homeassistant.components.iqvia.*
homeassistant.components.isy994.*
homeassistant.components.jellyfin.*
@ -193,6 +197,7 @@ homeassistant.components.lacrosse.*
homeassistant.components.lacrosse_view.*
homeassistant.components.lametric.*
homeassistant.components.laundrify.*
homeassistant.components.lawn_mower.*
homeassistant.components.lcn.*
homeassistant.components.ld2410_ble.*
homeassistant.components.lidarr.*
@ -209,6 +214,7 @@ homeassistant.components.luftdaten.*
homeassistant.components.mailbox.*
homeassistant.components.mastodon.*
homeassistant.components.matter.*
homeassistant.components.media_extractor.*
homeassistant.components.media_player.*
homeassistant.components.media_source.*
homeassistant.components.metoffice.*
@ -296,6 +302,7 @@ homeassistant.components.sonarr.*
homeassistant.components.speedtestdotnet.*
homeassistant.components.sql.*
homeassistant.components.ssdp.*
homeassistant.components.starlink.*
homeassistant.components.statistics.*
homeassistant.components.steamist.*
homeassistant.components.stookalert.*
@ -321,6 +328,7 @@ homeassistant.components.tplink.*
homeassistant.components.tplink_omada.*
homeassistant.components.tractive.*
homeassistant.components.tradfri.*
homeassistant.components.trafikverket_camera.*
homeassistant.components.trafikverket_ferry.*
homeassistant.components.trafikverket_train.*
homeassistant.components.trafikverket_weatherstation.*

View File

@ -19,8 +19,6 @@ build.json @home-assistant/supervisor
# Other code
/homeassistant/scripts/check_config.py @kellerza
/homeassistant/const.py @epenet
/homeassistant/util/ @epenet
# Integrations
/homeassistant/components/abode/ @shred86
@ -51,8 +49,6 @@ build.json @home-assistant/supervisor
/tests/components/airthings/ @danielhiversen
/homeassistant/components/airthings_ble/ @vincegio
/tests/components/airthings_ble/ @vincegio
/homeassistant/components/airtouch4/ @LonePurpleWolf
/tests/components/airtouch4/ @LonePurpleWolf
/homeassistant/components/airvisual/ @bachya
/tests/components/airvisual/ @bachya
/homeassistant/components/airvisual_pro/ @bachya
@ -211,6 +207,8 @@ build.json @home-assistant/supervisor
/tests/components/coinbase/ @tombrien
/homeassistant/components/color_extractor/ @GenericStudent
/tests/components/color_extractor/ @GenericStudent
/homeassistant/components/comelit/ @chemelli74
/tests/components/comelit/ @chemelli74
/homeassistant/components/comfoconnect/ @michaelarnauts
/tests/components/comfoconnect/ @michaelarnauts
/homeassistant/components/command_line/ @gjohansson-ST
@ -295,12 +293,10 @@ build.json @home-assistant/supervisor
/tests/components/dsmr/ @Robbie1221 @frenck
/homeassistant/components/dsmr_reader/ @depl0y @glodenox
/tests/components/dsmr_reader/ @depl0y @glodenox
/homeassistant/components/dunehd/ @bieniu
/tests/components/dunehd/ @bieniu
/homeassistant/components/duotecno/ @cereal2nd
/tests/components/duotecno/ @cereal2nd
/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 @Hummel95 @andarotajo
/tests/components/dwd_weather_warnings/ @runningman84 @stephan192 @Hummel95 @andarotajo
/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 @andarotajo
/tests/components/dwd_weather_warnings/ @runningman84 @stephan192 @andarotajo
/homeassistant/components/dynalite/ @ziv1234
/tests/components/dynalite/ @ziv1234
/homeassistant/components/eafm/ @Jc2k
@ -345,8 +341,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/enigma2/ @fbradyirl
/homeassistant/components/enocean/ @bdurrer
/tests/components/enocean/ @bdurrer
/homeassistant/components/enphase_envoy/ @gtdiehl
/tests/components/enphase_envoy/ @gtdiehl
/homeassistant/components/enphase_envoy/ @bdraco @cgarwood @dgomes @joostlek
/tests/components/enphase_envoy/ @bdraco @cgarwood @dgomes @joostlek
/homeassistant/components/entur_public_transport/ @hfurubotten
/homeassistant/components/environment_canada/ @gwww @michaeldavie
/tests/components/environment_canada/ @gwww @michaeldavie
@ -525,6 +521,8 @@ build.json @home-assistant/supervisor
/tests/components/homeassistant/ @home-assistant/core
/homeassistant/components/homeassistant_alerts/ @home-assistant/core
/tests/components/homeassistant_alerts/ @home-assistant/core
/homeassistant/components/homeassistant_green/ @home-assistant/core
/tests/components/homeassistant_green/ @home-assistant/core
/homeassistant/components/homeassistant_hardware/ @home-assistant/core
/tests/components/homeassistant_hardware/ @home-assistant/core
/homeassistant/components/homeassistant_sky_connect/ @home-assistant/core
@ -608,8 +606,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/iotawatt/ @gtdiehl @jyavenard
/tests/components/iotawatt/ @gtdiehl @jyavenard
/homeassistant/components/iperf3/ @rohankapoorcom
/homeassistant/components/ipma/ @dgomes @abmantis
/tests/components/ipma/ @dgomes @abmantis
/homeassistant/components/ipma/ @dgomes
/tests/components/ipma/ @dgomes
/homeassistant/components/ipp/ @ctalkington
/tests/components/ipp/ @ctalkington
/homeassistant/components/iqvia/ @bachya
@ -673,6 +671,8 @@ build.json @home-assistant/supervisor
/tests/components/launch_library/ @ludeeus @DurgNomis-drol
/homeassistant/components/laundrify/ @xLarry
/tests/components/laundrify/ @xLarry
/homeassistant/components/lawn_mower/ @home-assistant/core
/tests/components/lawn_mower/ @home-assistant/core
/homeassistant/components/lcn/ @alengwenus
/tests/components/lcn/ @alengwenus
/homeassistant/components/ld2410_ble/ @930913
@ -729,6 +729,7 @@ build.json @home-assistant/supervisor
/tests/components/mazda/ @bdr99
/homeassistant/components/meater/ @Sotolotl @emontnemery
/tests/components/meater/ @Sotolotl @emontnemery
/homeassistant/components/media_extractor/ @joostlek
/homeassistant/components/media_player/ @home-assistant/core
/tests/components/media_player/ @home-assistant/core
/homeassistant/components/media_source/ @hunterjm
@ -1028,8 +1029,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/repairs/ @home-assistant/core
/tests/components/repairs/ @home-assistant/core
/homeassistant/components/repetier/ @MTrab @ShadowBr0ther
/homeassistant/components/rest/ @epenet
/tests/components/rest/ @epenet
/homeassistant/components/rflink/ @javicalle
/tests/components/rflink/ @javicalle
/homeassistant/components/rfxtrx/ @danielhiversen @elupus @RobBie1221
@ -1058,8 +1057,8 @@ build.json @home-assistant/supervisor
/tests/components/rss_feed_template/ @home-assistant/core
/homeassistant/components/rtsp_to_webrtc/ @allenporter
/tests/components/rtsp_to_webrtc/ @allenporter
/homeassistant/components/ruckus_unleashed/ @gabe565
/tests/components/ruckus_unleashed/ @gabe565
/homeassistant/components/ruckus_unleashed/ @gabe565 @lanrat
/tests/components/ruckus_unleashed/ @gabe565 @lanrat
/homeassistant/components/ruuvi_gateway/ @akx
/tests/components/ruuvi_gateway/ @akx
/homeassistant/components/ruuvitag_ble/ @akx
@ -1077,9 +1076,11 @@ build.json @home-assistant/supervisor
/tests/components/scene/ @home-assistant/core
/homeassistant/components/schedule/ @home-assistant/core
/tests/components/schedule/ @home-assistant/core
/homeassistant/components/schlage/ @dknowles2
/tests/components/schlage/ @dknowles2
/homeassistant/components/schluter/ @prairieapps
/homeassistant/components/scrape/ @fabaff @gjohansson-ST @epenet
/tests/components/scrape/ @fabaff @gjohansson-ST @epenet
/homeassistant/components/scrape/ @fabaff @gjohansson-ST
/tests/components/scrape/ @fabaff @gjohansson-ST
/homeassistant/components/screenlogic/ @dieselrabbit @bdraco
/tests/components/screenlogic/ @dieselrabbit @bdraco
/homeassistant/components/script/ @home-assistant/core
@ -1226,8 +1227,8 @@ build.json @home-assistant/supervisor
/tests/components/switch_as_x/ @home-assistant/core
/homeassistant/components/switchbee/ @jafar-atili
/tests/components/switchbee/ @jafar-atili
/homeassistant/components/switchbot/ @bdraco @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski
/tests/components/switchbot/ @bdraco @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski
/homeassistant/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski
/tests/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski
/homeassistant/components/switcher_kis/ @thecode
/tests/components/switcher_kis/ @thecode
/homeassistant/components/switchmate/ @danielhiversen @qiz-li
@ -1298,6 +1299,8 @@ build.json @home-assistant/supervisor
/tests/components/trace/ @home-assistant/core
/homeassistant/components/tractive/ @Danielhiversen @zhulik @bieniu
/tests/components/tractive/ @Danielhiversen @zhulik @bieniu
/homeassistant/components/trafikverket_camera/ @gjohansson-ST
/tests/components/trafikverket_camera/ @gjohansson-ST
/homeassistant/components/trafikverket_ferry/ @gjohansson-ST
/tests/components/trafikverket_ferry/ @gjohansson-ST
/homeassistant/components/trafikverket_train/ @endor-force @gjohansson-ST
@ -1365,6 +1368,8 @@ build.json @home-assistant/supervisor
/tests/components/vizio/ @raman325
/homeassistant/components/vlc_telnet/ @rodripf @MartinHjelmare
/tests/components/vlc_telnet/ @rodripf @MartinHjelmare
/homeassistant/components/vodafone_station/ @paoloantinori @chemelli74
/tests/components/vodafone_station/ @paoloantinori @chemelli74
/homeassistant/components/voip/ @balloob @synesthesiam
/tests/components/voip/ @balloob @synesthesiam
/homeassistant/components/volumio/ @OnFreund
@ -1375,6 +1380,8 @@ build.json @home-assistant/supervisor
/tests/components/vulcan/ @Antoni-Czaplicki
/homeassistant/components/wake_on_lan/ @ntilley905
/tests/components/wake_on_lan/ @ntilley905
/homeassistant/components/wake_word/ @home-assistant/core @synesthesiam
/tests/components/wake_word/ @home-assistant/core @synesthesiam
/homeassistant/components/wallbox/ @hesselonline
/tests/components/wallbox/ @hesselonline
/homeassistant/components/waqi/ @andrey-git
@ -1438,6 +1445,7 @@ build.json @home-assistant/supervisor
/tests/components/yamaha_musiccast/ @vigonotion @micha91
/homeassistant/components/yandex_transport/ @rishatik92 @devbis
/tests/components/yandex_transport/ @rishatik92 @devbis
/homeassistant/components/yardian/ @h3l1o5
/homeassistant/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015
/tests/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015
/homeassistant/components/yeelightsunflower/ @lindsaymarkward

View File

@ -1,10 +1,10 @@
image: ghcr.io/home-assistant/{arch}-homeassistant
build_from:
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.07.0
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.07.0
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.07.0
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.07.0
i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.07.0
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.08.0
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.08.0
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.08.0
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.08.0
i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.08.0
codenotary:
signer: notary@home-assistant.io
base_image: notary@home-assistant.io

View File

@ -148,16 +148,6 @@ def get_arguments() -> argparse.Namespace:
return arguments
def cmdline() -> list[str]:
"""Collect path and arguments to re-execute the current hass instance."""
if os.path.basename(sys.argv[0]) == "__main__.py":
modulepath = os.path.dirname(sys.argv[0])
os.environ["PYTHONPATH"] = os.path.dirname(modulepath)
return [sys.executable, "-m", "homeassistant"] + list(sys.argv[1:])
return sys.argv
def check_threads() -> None:
"""Check if there are any lingering threads."""
try:

View File

@ -8,7 +8,7 @@ from typing import Any, Generic, Self, TypeVar, overload
_T = TypeVar("_T")
class cached_property(Generic[_T]): # pylint: disable=invalid-name
class cached_property(Generic[_T]):
"""Backport of Python 3.12's cached_property.
Includes https://github.com/python/cpython/pull/101890/files

View File

@ -110,8 +110,7 @@ async def async_setup_hass(
runtime_config: RuntimeConfig,
) -> core.HomeAssistant | None:
"""Set up Home Assistant."""
hass = core.HomeAssistant()
hass.config.config_dir = runtime_config.config_dir
hass = core.HomeAssistant(runtime_config.config_dir)
async_enable_logging(
hass,
@ -134,6 +133,7 @@ async def async_setup_hass(
_LOGGER.info("Config directory: %s", runtime_config.config_dir)
loader.async_setup(hass)
config_dict = None
basic_setup_success = False
@ -177,14 +177,15 @@ async def async_setup_hass(
old_config = hass.config
old_logging = hass.data.get(DATA_LOGGING)
hass = core.HomeAssistant()
hass = core.HomeAssistant(old_config.config_dir)
if old_logging:
hass.data[DATA_LOGGING] = old_logging
hass.config.skip_pip = old_config.skip_pip
hass.config.skip_pip_packages = old_config.skip_pip_packages
hass.config.internal_url = old_config.internal_url
hass.config.external_url = old_config.external_url
hass.config.config_dir = old_config.config_dir
# Setup loader cache after the config dir has been set
loader.async_setup(hass)
if safe_mode:
_LOGGER.info("Starting in safe mode")

View File

@ -2,6 +2,7 @@
"domain": "trafikverket",
"name": "Trafikverket",
"integrations": [
"trafikverket_camera",
"trafikverket_ferry",
"trafikverket_train",
"trafikverket_weatherstation"

View File

@ -28,6 +28,7 @@ from homeassistant.const import (
from homeassistant.core import Event, HomeAssistant, ServiceCall
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, entity
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import dispatcher_send
from .const import ATTRIBUTION, CONF_POLLING, DOMAIN, LOGGER
@ -287,14 +288,14 @@ class AbodeDevice(AbodeEntity):
"""Initialize Abode device."""
super().__init__(data)
self._device = device
self._attr_unique_id = device.device_uuid
self._attr_unique_id = device.uuid
async def async_added_to_hass(self) -> None:
"""Subscribe to device events."""
await super().async_added_to_hass()
await self.hass.async_add_executor_job(
self._data.abode.events.add_device_callback,
self._device.device_id,
self._device.id,
self._update_callback,
)
@ -302,7 +303,7 @@ class AbodeDevice(AbodeEntity):
"""Unsubscribe from device events."""
await super().async_will_remove_from_hass()
await self.hass.async_add_executor_job(
self._data.abode.events.remove_all_device_callbacks, self._device.device_id
self._data.abode.events.remove_all_device_callbacks, self._device.id
)
def update(self) -> None:
@ -313,17 +314,17 @@ class AbodeDevice(AbodeEntity):
def extra_state_attributes(self) -> dict[str, str]:
"""Return the state attributes."""
return {
"device_id": self._device.device_id,
"device_id": self._device.id,
"battery_low": self._device.battery_low,
"no_response": self._device.no_response,
"device_type": self._device.type,
}
@property
def device_info(self) -> entity.DeviceInfo:
def device_info(self) -> DeviceInfo:
"""Return device registry information for this entity."""
return entity.DeviceInfo(
identifiers={(DOMAIN, self._device.device_id)},
return DeviceInfo(
identifiers={(DOMAIN, self._device.id)},
manufacturer="Abode",
model=self._device.type,
name=self._device.name,

View File

@ -69,7 +69,7 @@ class AbodeAlarm(AbodeDevice, alarm.AlarmControlPanelEntity):
def extra_state_attributes(self) -> dict[str, str]:
"""Return the state attributes."""
return {
"device_id": self._device.device_id,
"device_id": self._device.id,
"battery_backup": self._device.battery,
"cellular_backup": self._device.is_cellular,
}

View File

@ -30,7 +30,7 @@ async def async_setup_entry(
data: AbodeSystem = hass.data[DOMAIN]
async_add_entities(
AbodeCamera(data, device, TIMELINE.CAPTURE_IMAGE) # pylint: disable=no-member
AbodeCamera(data, device, TIMELINE.CAPTURE_IMAGE)
for device in data.abode.get_devices(generic_type=CONST.TYPE_CAMERA)
)

View File

@ -1,6 +1,8 @@
"""Support for Abode Security System sensors."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import cast
from jaraco.abode.devices.sensor import Sensor as AbodeSense
@ -12,25 +14,52 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import LIGHT_LUX
from homeassistant.const import LIGHT_LUX, PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AbodeDevice, AbodeSystem
from .const import DOMAIN
SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
ABODE_TEMPERATURE_UNIT_HA_UNIT = {
CONST.UNIT_FAHRENHEIT: UnitOfTemperature.FAHRENHEIT,
CONST.UNIT_CELSIUS: UnitOfTemperature.CELSIUS,
}
@dataclass
class AbodeSensorDescriptionMixin:
"""Mixin for Abode sensor."""
value_fn: Callable[[AbodeSense], float]
native_unit_of_measurement_fn: Callable[[AbodeSense], str]
@dataclass
class AbodeSensorDescription(SensorEntityDescription, AbodeSensorDescriptionMixin):
"""Class describing Abode sensor entities."""
SENSOR_TYPES: tuple[AbodeSensorDescription, ...] = (
AbodeSensorDescription(
key=CONST.TEMP_STATUS_KEY,
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement_fn=lambda device: ABODE_TEMPERATURE_UNIT_HA_UNIT[
device.temp_unit
],
value_fn=lambda device: cast(float, device.temp),
),
SensorEntityDescription(
AbodeSensorDescription(
key=CONST.HUMI_STATUS_KEY,
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement_fn=lambda _: PERCENTAGE,
value_fn=lambda device: cast(float, device.humidity),
),
SensorEntityDescription(
AbodeSensorDescription(
key=CONST.LUX_STATUS_KEY,
device_class=SensorDeviceClass.ILLUMINANCE,
native_unit_of_measurement_fn=lambda _: LIGHT_LUX,
value_fn=lambda device: cast(float, device.lux),
),
)
@ -52,32 +81,26 @@ async def async_setup_entry(
class AbodeSensor(AbodeDevice, SensorEntity):
"""A sensor implementation for Abode devices."""
entity_description: AbodeSensorDescription
_device: AbodeSense
def __init__(
self,
data: AbodeSystem,
device: AbodeSense,
description: SensorEntityDescription,
description: AbodeSensorDescription,
) -> None:
"""Initialize a sensor for an Abode device."""
super().__init__(data, device)
self.entity_description = description
self._attr_unique_id = f"{device.device_uuid}-{description.key}"
if description.key == CONST.TEMP_STATUS_KEY:
self._attr_native_unit_of_measurement = device.temp_unit
elif description.key == CONST.HUMI_STATUS_KEY:
self._attr_native_unit_of_measurement = device.humidity_unit
elif description.key == CONST.LUX_STATUS_KEY:
self._attr_native_unit_of_measurement = LIGHT_LUX
self._attr_unique_id = f"{device.uuid}-{description.key}"
@property
def native_value(self) -> float | None:
def native_value(self) -> float:
"""Return the state of the sensor."""
if self.entity_description.key == CONST.TEMP_STATUS_KEY:
return cast(float, self._device.temp)
if self.entity_description.key == CONST.HUMI_STATUS_KEY:
return cast(float, self._device.humidity)
if self.entity_description.key == CONST.LUX_STATUS_KEY:
return cast(float, self._device.lux)
return None
return self.entity_description.value_fn(self._device)
@property
def native_unit_of_measurement(self) -> str:
"""Return the native unit of measurement."""
return self.entity_description.native_unit_of_measurement_fn(self._device)

View File

@ -1,6 +1,7 @@
"""The AccuWeather component."""
from __future__ import annotations
from asyncio import timeout
from datetime import timedelta
import logging
from typing import Any
@ -8,7 +9,6 @@ from typing import Any
from accuweather import AccuWeather, ApiError, InvalidApiKeyError, RequestsExceededError
from aiohttp import ClientSession
from aiohttp.client_exceptions import ClientConnectorError
from async_timeout import timeout
from homeassistant.components.sensor import DOMAIN as SENSOR_PLATFORM
from homeassistant.config_entries import ConfigEntry
@ -16,8 +16,7 @@ from homeassistant.const import CONF_API_KEY, CONF_NAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import ATTR_FORECAST, CONF_FORECAST, DOMAIN, MANUFACTURER

View File

@ -2,12 +2,12 @@
from __future__ import annotations
import asyncio
from asyncio import timeout
from typing import Any
from accuweather import AccuWeather, ApiError, InvalidApiKeyError, RequestsExceededError
from aiohttp import ClientError
from aiohttp.client_exceptions import ClientConnectorError
from async_timeout import timeout
import voluptuous as vol
from homeassistant import config_entries

View File

@ -50,3 +50,8 @@ CONDITION_CLASSES: Final[dict[str, list[int]]] = {
ATTR_CONDITION_SUNNY: [1, 2, 5],
ATTR_CONDITION_WINDY: [32],
}
CONDITION_MAP = {
cond_code: cond_ha
for cond_ha, cond_codes in CONDITION_CLASSES.items()
for cond_code in cond_codes
}

View File

@ -59,193 +59,280 @@ class AccuWeatherSensorDescription(
"""Class describing AccuWeather sensor entities."""
attr_fn: Callable[[dict[str, Any]], dict[str, Any]] = lambda _: {}
day: int | None = None
FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
*(
AccuWeatherSensorDescription(
key="AirQuality",
icon="mdi:air-filter",
name="Air quality",
value_fn=lambda data: cast(str, data[ATTR_CATEGORY]),
device_class=SensorDeviceClass.ENUM,
options=["good", "hazardous", "high", "low", "moderate", "unhealthy"],
translation_key="air_quality",
translation_key=f"air_quality_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
*(
AccuWeatherSensorDescription(
key="CloudCoverDay",
icon="mdi:weather-cloudy",
name="Cloud cover day",
entity_registry_enabled_default=False,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda data: cast(int, data),
translation_key=f"cloud_cover_day_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
*(
AccuWeatherSensorDescription(
key="CloudCoverNight",
icon="mdi:weather-cloudy",
name="Cloud cover night",
entity_registry_enabled_default=False,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda data: cast(int, data),
translation_key=f"cloud_cover_night_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
*(
AccuWeatherSensorDescription(
key="Grass",
icon="mdi:grass",
name="Grass pollen",
entity_registry_enabled_default=False,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
translation_key="grass_pollen",
translation_key=f"grass_pollen_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
*(
AccuWeatherSensorDescription(
key="HoursOfSun",
icon="mdi:weather-partly-cloudy",
name="Hours of sun",
native_unit_of_measurement=UnitOfTime.HOURS,
value_fn=lambda data: cast(float, data),
translation_key=f"hours_of_sun_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
*(
AccuWeatherSensorDescription(
key="LongPhraseDay",
name="Condition day",
value_fn=lambda data: cast(str, data),
translation_key=f"condition_day_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
*(
AccuWeatherSensorDescription(
key="LongPhraseNight",
name="Condition night",
value_fn=lambda data: cast(str, data),
translation_key=f"condition_night_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
*(
AccuWeatherSensorDescription(
key="Mold",
icon="mdi:blur",
name="Mold pollen",
entity_registry_enabled_default=False,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
translation_key="mold_pollen",
translation_key=f"mold_pollen_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
*(
AccuWeatherSensorDescription(
key="Ragweed",
icon="mdi:sprout",
name="Ragweed pollen",
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
entity_registry_enabled_default=False,
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
translation_key="ragweed_pollen",
translation_key=f"ragweed_pollen_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
*(
AccuWeatherSensorDescription(
key="RealFeelTemperatureMax",
device_class=SensorDeviceClass.TEMPERATURE,
name="RealFeel temperature max",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: cast(float, data[ATTR_VALUE]),
translation_key=f"realfeel_temperature_max_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
*(
AccuWeatherSensorDescription(
key="RealFeelTemperatureMin",
device_class=SensorDeviceClass.TEMPERATURE,
name="RealFeel temperature min",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: cast(float, data[ATTR_VALUE]),
translation_key=f"realfeel_temperature_min_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
*(
AccuWeatherSensorDescription(
key="RealFeelTemperatureShadeMax",
device_class=SensorDeviceClass.TEMPERATURE,
name="RealFeel temperature shade max",
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: cast(float, data[ATTR_VALUE]),
translation_key=f"realfeel_temperature_shade_max_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
*(
AccuWeatherSensorDescription(
key="RealFeelTemperatureShadeMin",
device_class=SensorDeviceClass.TEMPERATURE,
name="RealFeel temperature shade min",
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: cast(float, data[ATTR_VALUE]),
translation_key=f"realfeel_temperature_shade_min_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
*(
AccuWeatherSensorDescription(
key="SolarIrradianceDay",
icon="mdi:weather-sunny",
name="Solar irradiance day",
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER,
value_fn=lambda data: cast(float, data[ATTR_VALUE]),
translation_key=f"solar_irradiance_day_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
*(
AccuWeatherSensorDescription(
key="SolarIrradianceNight",
icon="mdi:weather-sunny",
name="Solar irradiance night",
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER,
value_fn=lambda data: cast(float, data[ATTR_VALUE]),
translation_key=f"solar_irradiance_night_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
*(
AccuWeatherSensorDescription(
key="ThunderstormProbabilityDay",
icon="mdi:weather-lightning",
name="Thunderstorm probability day",
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda data: cast(int, data),
translation_key=f"thunderstorm_probability_day_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
*(
AccuWeatherSensorDescription(
key="ThunderstormProbabilityNight",
icon="mdi:weather-lightning",
name="Thunderstorm probability night",
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda data: cast(int, data),
translation_key=f"thunderstorm_probability_night_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
*(
AccuWeatherSensorDescription(
key="Tree",
icon="mdi:tree-outline",
name="Tree pollen",
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
entity_registry_enabled_default=False,
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
translation_key="tree_pollen",
translation_key=f"tree_pollen_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
*(
AccuWeatherSensorDescription(
key="UVIndex",
icon="mdi:weather-sunny",
name="UV index",
native_unit_of_measurement=UV_INDEX,
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
translation_key="uv_index",
translation_key=f"uv_index_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
*(
AccuWeatherSensorDescription(
key="WindGustDay",
device_class=SensorDeviceClass.WIND_SPEED,
name="Wind gust day",
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]),
attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]},
translation_key=f"wind_gust_speed_day_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
*(
AccuWeatherSensorDescription(
key="WindGustNight",
device_class=SensorDeviceClass.WIND_SPEED,
name="Wind gust night",
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]),
attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]},
translation_key=f"wind_gust_speed_night_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
*(
AccuWeatherSensorDescription(
key="WindDay",
device_class=SensorDeviceClass.WIND_SPEED,
name="Wind day",
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]),
attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]},
translation_key=f"wind_speed_day_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
*(
AccuWeatherSensorDescription(
key="WindNight",
device_class=SensorDeviceClass.WIND_SPEED,
name="Wind night",
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]),
attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]},
translation_key=f"wind_speed_night_{day}d",
day=day,
)
for day in range(MAX_FORECAST_DAYS + 1)
),
)
@ -253,118 +340,117 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
AccuWeatherSensorDescription(
key="ApparentTemperature",
device_class=SensorDeviceClass.TEMPERATURE,
name="Apparent temperature",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]),
translation_key="apparent_temperature",
),
AccuWeatherSensorDescription(
key="Ceiling",
device_class=SensorDeviceClass.DISTANCE,
icon="mdi:weather-fog",
name="Cloud ceiling",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfLength.METERS,
value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]),
suggested_display_precision=0,
translation_key="cloud_ceiling",
),
AccuWeatherSensorDescription(
key="CloudCover",
icon="mdi:weather-cloudy",
name="Cloud cover",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda data: cast(int, data),
translation_key="cloud_cover",
),
AccuWeatherSensorDescription(
key="DewPoint",
device_class=SensorDeviceClass.TEMPERATURE,
name="Dew point",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]),
translation_key="dew_point",
),
AccuWeatherSensorDescription(
key="RealFeelTemperature",
device_class=SensorDeviceClass.TEMPERATURE,
name="RealFeel temperature",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]),
translation_key="realfeel_temperature",
),
AccuWeatherSensorDescription(
key="RealFeelTemperatureShade",
device_class=SensorDeviceClass.TEMPERATURE,
name="RealFeel temperature shade",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]),
translation_key="realfeel_temperature_shade",
),
AccuWeatherSensorDescription(
key="Precipitation",
device_class=SensorDeviceClass.PRECIPITATION_INTENSITY,
name="Precipitation",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR,
value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]),
attr_fn=lambda data: {"type": data["PrecipitationType"]},
translation_key="precipitation",
),
AccuWeatherSensorDescription(
key="PressureTendency",
device_class=SensorDeviceClass.ENUM,
icon="mdi:gauge",
name="Pressure tendency",
options=["falling", "rising", "steady"],
translation_key="pressure_tendency",
value_fn=lambda data: cast(str, data["LocalizedText"]).lower(),
translation_key="pressure_tendency",
),
AccuWeatherSensorDescription(
key="UVIndex",
icon="mdi:weather-sunny",
name="UV index",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UV_INDEX,
value_fn=lambda data: cast(int, data),
attr_fn=lambda data: {ATTR_LEVEL: data["UVIndexText"]},
translation_key="uv_index",
),
AccuWeatherSensorDescription(
key="WetBulbTemperature",
device_class=SensorDeviceClass.TEMPERATURE,
name="Wet bulb temperature",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]),
translation_key="wet_bulb_temperature",
),
AccuWeatherSensorDescription(
key="WindChillTemperature",
device_class=SensorDeviceClass.TEMPERATURE,
name="Wind chill temperature",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]),
translation_key="wind_chill_temperature",
),
AccuWeatherSensorDescription(
key="Wind",
device_class=SensorDeviceClass.WIND_SPEED,
name="Wind",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
value_fn=lambda data: cast(float, data[ATTR_SPEED][API_METRIC][ATTR_VALUE]),
translation_key="wind_speed",
),
AccuWeatherSensorDescription(
key="WindGust",
device_class=SensorDeviceClass.WIND_SPEED,
name="Wind gust",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
value_fn=lambda data: cast(float, data[ATTR_SPEED][API_METRIC][ATTR_VALUE]),
translation_key="wind_gust_speed",
),
)
@ -381,14 +467,12 @@ async def async_setup_entry(
]
if coordinator.forecast:
for description in FORECAST_SENSOR_TYPES:
# Some air quality/allergy sensors are only available for certain
# locations.
sensors.extend(
AccuWeatherSensor(coordinator, description, forecast_day=day)
for day in range(MAX_FORECAST_DAYS + 1)
for description in FORECAST_SENSOR_TYPES
if description.key in coordinator.data[ATTR_FORECAST][0]
)
if description.key not in coordinator.data[ATTR_FORECAST][description.day]:
continue
sensors.append(AccuWeatherSensor(coordinator, description))
async_add_entities(sensors)
@ -406,25 +490,21 @@ class AccuWeatherSensor(
self,
coordinator: AccuWeatherDataUpdateCoordinator,
description: AccuWeatherSensorDescription,
forecast_day: int | None = None,
) -> None:
"""Initialize."""
super().__init__(coordinator)
self.forecast_day = description.day
self.entity_description = description
self._sensor_data = _get_sensor_data(
coordinator.data, description.key, forecast_day
)
if forecast_day is not None:
self._attr_name = f"{description.name} {forecast_day}d"
self._attr_unique_id = (
f"{coordinator.location_key}-{description.key}-{forecast_day}".lower()
coordinator.data, description.key, self.forecast_day
)
if self.forecast_day is not None:
self._attr_unique_id = f"{coordinator.location_key}-{description.key}-{self.forecast_day}".lower()
else:
self._attr_unique_id = (
f"{coordinator.location_key}-{description.key}".lower()
)
self._attr_device_info = coordinator.device_info
self.forecast_day = forecast_day
@property
def native_value(self) -> str | int | float | None:

View File

@ -24,14 +24,8 @@
},
"entity": {
"sensor": {
"pressure_tendency": {
"state": {
"steady": "Steady",
"rising": "Rising",
"falling": "Falling"
}
},
"air_quality": {
"air_quality_0d": {
"name": "Air quality today",
"state": {
"good": "Good",
"hazardous": "Hazardous",
@ -41,80 +35,761 @@
"unhealthy": "Unhealthy"
}
},
"grass_pollen": {
"air_quality_1d": {
"name": "Air quality day 1",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
},
"air_quality_2d": {
"name": "Air quality day 2",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
},
"air_quality_3d": {
"name": "Air quality day 3",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
},
"air_quality_4d": {
"name": "Air quality day 4",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
},
"apparent_temperature": {
"name": "Apparent temperature"
},
"cloud_ceiling": {
"name": "Cloud ceiling"
},
"cloud_cover": {
"name": "Cloud cover"
},
"cloud_cover_day_0d": {
"name": "Cloud cover today"
},
"cloud_cover_day_1d": {
"name": "Cloud cover day 1"
},
"cloud_cover_day_2d": {
"name": "Cloud cover day 2"
},
"cloud_cover_day_3d": {
"name": "Cloud cover day 3"
},
"cloud_cover_day_4d": {
"name": "Cloud cover day 4"
},
"cloud_cover_night_0d": {
"name": "Cloud cover tonight"
},
"cloud_cover_night_1d": {
"name": "Cloud cover night 1"
},
"cloud_cover_night_2d": {
"name": "Cloud cover night 2"
},
"cloud_cover_night_3d": {
"name": "Cloud cover night 3"
},
"cloud_cover_night_4d": {
"name": "Cloud cover night 4"
},
"condition_day_0d": {
"name": "Condition today"
},
"condition_day_1d": {
"name": "Condition day 1"
},
"condition_day_2d": {
"name": "Condition day 2"
},
"condition_day_3d": {
"name": "Condition day 3"
},
"condition_day_4d": {
"name": "Condition day 4"
},
"condition_night_0d": {
"name": "Condition tonight"
},
"condition_night_1d": {
"name": "Condition night 1"
},
"condition_night_2d": {
"name": "Condition night 2"
},
"condition_night_3d": {
"name": "Condition night 3"
},
"condition_night_4d": {
"name": "Condition night 4"
},
"dew_point": {
"name": "Dew point"
},
"grass_pollen_0d": {
"name": "Grass pollen today",
"state_attributes": {
"level": {
"name": "Level",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]"
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"mold_pollen": {
"grass_pollen_1d": {
"name": "Grass pollen day 1",
"state_attributes": {
"level": {
"name": "Level",
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]"
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"ragweed_pollen": {
"grass_pollen_2d": {
"name": "Grass pollen day 2",
"state_attributes": {
"level": {
"name": "Level",
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]"
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"tree_pollen": {
"grass_pollen_3d": {
"name": "Grass pollen day 3",
"state_attributes": {
"level": {
"name": "Level",
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]"
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"grass_pollen_4d": {
"name": "Grass pollen day 4",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"hours_of_sun_0d": {
"name": "Hours of sun today"
},
"hours_of_sun_1d": {
"name": "Hours of sun day 1"
},
"hours_of_sun_2d": {
"name": "Hours of sun day 2"
},
"hours_of_sun_3d": {
"name": "Hours of sun day 3"
},
"hours_of_sun_4d": {
"name": "Hours of sun day 4"
},
"mold_pollen_0d": {
"name": "Mold pollen today",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"mold_pollen_1d": {
"name": "Mold pollen day 1",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"mold_pollen_2d": {
"name": "Mold pollen day 2",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"mold_pollen_3d": {
"name": "Mold pollen day 3",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"mold_pollen_4d": {
"name": "Mold pollen day 4",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"precipitation": {
"name": "[%key:component::sensor::entity_component::precipitation::name%]"
},
"pressure_tendency": {
"name": "Pressure tendency",
"state": {
"steady": "Steady",
"rising": "Rising",
"falling": "Falling"
}
},
"ragweed_pollen_0d": {
"name": "Ragweed pollen today",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"ragweed_pollen_1d": {
"name": "Ragweed pollen day 1",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"ragweed_pollen_2d": {
"name": "Ragweed pollen day 2",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"ragweed_pollen_3d": {
"name": "Ragweed pollen day 3",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"ragweed_pollen_4d": {
"name": "Ragweed pollen day 4",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"realfeel_temperature": {
"name": "RealFeel temperature"
},
"realfeel_temperature_max_0d": {
"name": "RealFeel temperature max today"
},
"realfeel_temperature_max_1d": {
"name": "RealFeel temperature max day 1"
},
"realfeel_temperature_max_2d": {
"name": "RealFeel temperature max day 2"
},
"realfeel_temperature_max_3d": {
"name": "RealFeel temperature max day 3"
},
"realfeel_temperature_max_4d": {
"name": "RealFeel temperature max day 4"
},
"realfeel_temperature_min_0d": {
"name": "RealFeel temperature min today"
},
"realfeel_temperature_min_1d": {
"name": "RealFeel temperature min day 1"
},
"realfeel_temperature_min_2d": {
"name": "RealFeel temperature min day 2"
},
"realfeel_temperature_min_3d": {
"name": "RealFeel temperature min day 3"
},
"realfeel_temperature_min_4d": {
"name": "RealFeel temperature min day 4"
},
"realfeel_temperature_shade": {
"name": "RealFeel temperature shade"
},
"realfeel_temperature_shade_max_0d": {
"name": "RealFeel temperature shade max today"
},
"realfeel_temperature_shade_max_1d": {
"name": "RealFeel temperature shade max day 1"
},
"realfeel_temperature_shade_max_2d": {
"name": "RealFeel temperature shade max day 2"
},
"realfeel_temperature_shade_max_3d": {
"name": "RealFeel temperature shade max day 3"
},
"realfeel_temperature_shade_max_4d": {
"name": "RealFeel temperature shade max day 4"
},
"realfeel_temperature_shade_min_0d": {
"name": "RealFeel temperature shade min today"
},
"realfeel_temperature_shade_min_1d": {
"name": "RealFeel temperature shade min day 1"
},
"realfeel_temperature_shade_min_2d": {
"name": "RealFeel temperature shade min day 2"
},
"realfeel_temperature_shade_min_3d": {
"name": "RealFeel temperature shade min day 3"
},
"realfeel_temperature_shade_min_4d": {
"name": "RealFeel temperature shade min day 4"
},
"solar_irradiance_day_0d": {
"name": "Solar irradiance today"
},
"solar_irradiance_day_1d": {
"name": "Solar irradiance day 1"
},
"solar_irradiance_day_2d": {
"name": "Solar irradiance day 2"
},
"solar_irradiance_day_3d": {
"name": "Solar irradiance day 3"
},
"solar_irradiance_day_4d": {
"name": "Solar irradiance day 4"
},
"solar_irradiance_night_0d": {
"name": "Solar irradiance tonight"
},
"solar_irradiance_night_1d": {
"name": "Solar irradiance night 1"
},
"solar_irradiance_night_2d": {
"name": "Solar irradiance night 2"
},
"solar_irradiance_night_3d": {
"name": "Solar irradiance night 3"
},
"solar_irradiance_night_4d": {
"name": "Solar irradiance night 4"
},
"thunderstorm_probability_day_0d": {
"name": "Thunderstorm probability today"
},
"thunderstorm_probability_day_1d": {
"name": "Thunderstorm probability day 1"
},
"thunderstorm_probability_day_2d": {
"name": "Thunderstorm probability day 2"
},
"thunderstorm_probability_day_3d": {
"name": "Thunderstorm probability day 3"
},
"thunderstorm_probability_day_4d": {
"name": "Thunderstorm probability day 4"
},
"thunderstorm_probability_night_0d": {
"name": "Thunderstorm probability tonight"
},
"thunderstorm_probability_night_1d": {
"name": "Thunderstorm probability night 1"
},
"thunderstorm_probability_night_2d": {
"name": "Thunderstorm probability night 2"
},
"thunderstorm_probability_night_3d": {
"name": "Thunderstorm probability night 3"
},
"thunderstorm_probability_night_4d": {
"name": "Thunderstorm probability night 4"
},
"tree_pollen_0d": {
"name": "Tree pollen today",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"tree_pollen_1d": {
"name": "Tree pollen day 1",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"tree_pollen_2d": {
"name": "Tree pollen day 2",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"tree_pollen_3d": {
"name": "Tree pollen day 3",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"tree_pollen_4d": {
"name": "Tree pollen day 4",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"uv_index": {
"name": "UV index",
"state_attributes": {
"level": {
"name": "Level",
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]"
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"uv_index_0d": {
"name": "UV index today",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"uv_index_1d": {
"name": "UV index day 1",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"uv_index_2d": {
"name": "UV index day 2",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"uv_index_3d": {
"name": "UV index day 3",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"uv_index_4d": {
"name": "UV index day 4",
"state_attributes": {
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]",
"state": {
"good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]",
"hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]",
"high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]",
"unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]"
}
}
}
},
"wet_bulb_temperature": {
"name": "Wet bulb temperature"
},
"wind_speed": {
"name": "[%key:component::weather::entity_component::_::state_attributes::wind_speed::name%]"
},
"wind_chill_temperature": {
"name": "Wind chill temperature"
},
"wind_gust_speed": {
"name": "[%key:component::weather::entity_component::_::state_attributes::wind_gust_speed::name%]"
},
"wind_gust_speed_day_0d": {
"name": "Wind gust speed today"
},
"wind_gust_speed_day_1d": {
"name": "Wind gust speed day 1"
},
"wind_gust_speed_day_2d": {
"name": "Wind gust speed day 2"
},
"wind_gust_speed_day_3d": {
"name": "Wind gust speed day 3"
},
"wind_gust_speed_day_4d": {
"name": "Wind gust speed day 4"
},
"wind_gust_speed_night_0d": {
"name": "Wind gust speed tonight"
},
"wind_gust_speed_night_1d": {
"name": "Wind gust speed night 1"
},
"wind_gust_speed_night_2d": {
"name": "Wind gust speed night 2"
},
"wind_gust_speed_night_3d": {
"name": "Wind gust speed night 3"
},
"wind_gust_speed_night_4d": {
"name": "Wind gust speed night 4"
},
"wind_speed_day_0d": {
"name": "Wind speed today"
},
"wind_speed_day_1d": {
"name": "Wind speed day 1"
},
"wind_speed_day_2d": {
"name": "Wind speed day 2"
},
"wind_speed_day_3d": {
"name": "Wind speed day 3"
},
"wind_speed_day_4d": {
"name": "Wind speed day 4"
},
"wind_speed_night_0d": {
"name": "Wind speed tonight"
},
"wind_speed_night_1d": {
"name": "Wind speed night 1"
},
"wind_speed_night_2d": {
"name": "Wind speed night 2"
},
"wind_speed_night_3d": {
"name": "Wind speed night 3"
},
"wind_speed_night_4d": {
"name": "Wind speed night 4"
}
}
},

View File

@ -17,7 +17,8 @@ from homeassistant.components.weather import (
ATTR_FORECAST_UV_INDEX,
ATTR_FORECAST_WIND_BEARING,
Forecast,
WeatherEntity,
SingleCoordinatorWeatherEntity,
WeatherEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@ -27,9 +28,8 @@ from homeassistant.const import (
UnitOfSpeed,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util.dt import utc_from_timestamp
from . import AccuWeatherDataUpdateCoordinator
@ -40,7 +40,7 @@ from .const import (
ATTR_SPEED,
ATTR_VALUE,
ATTRIBUTION,
CONDITION_CLASSES,
CONDITION_MAP,
DOMAIN,
)
@ -58,7 +58,7 @@ async def async_setup_entry(
class AccuWeatherEntity(
CoordinatorEntity[AccuWeatherDataUpdateCoordinator], WeatherEntity
SingleCoordinatorWeatherEntity[AccuWeatherDataUpdateCoordinator]
):
"""Define an AccuWeather entity."""
@ -68,9 +68,6 @@ class AccuWeatherEntity(
def __init__(self, coordinator: AccuWeatherDataUpdateCoordinator) -> None:
"""Initialize."""
super().__init__(coordinator)
# Coordinator data is used also for sensors which don't have units automatically
# converted, hence the weather entity's native units follow the configured unit
# system
self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS
self._attr_native_pressure_unit = UnitOfPressure.HPA
self._attr_native_temperature_unit = UnitOfTemperature.CELSIUS
@ -79,18 +76,13 @@ class AccuWeatherEntity(
self._attr_unique_id = coordinator.location_key
self._attr_attribution = ATTRIBUTION
self._attr_device_info = coordinator.device_info
if self.coordinator.forecast:
self._attr_supported_features = WeatherEntityFeature.FORECAST_DAILY
@property
def condition(self) -> str | None:
"""Return the current condition."""
try:
return [
k
for k, v in CONDITION_CLASSES.items()
if self.coordinator.data["WeatherIcon"] in v
][0]
except IndexError:
return None
return CONDITION_MAP.get(self.coordinator.data["WeatherIcon"])
@property
def cloud_coverage(self) -> float:
@ -180,9 +172,12 @@ class AccuWeatherEntity(
],
ATTR_FORECAST_UV_INDEX: item["UVIndex"][ATTR_VALUE],
ATTR_FORECAST_WIND_BEARING: item["WindDay"][ATTR_DIRECTION]["Degrees"],
ATTR_FORECAST_CONDITION: [
k for k, v in CONDITION_CLASSES.items() if item["IconDay"] in v
][0],
ATTR_FORECAST_CONDITION: CONDITION_MAP.get(item["IconDay"]),
}
for item in self.coordinator.data[ATTR_FORECAST]
]
@callback
def _async_forecast_daily(self) -> list[Forecast] | None:
"""Return the daily forecast in native units."""
return self.forecast

View File

@ -74,9 +74,9 @@ class AcmedaBase(entity.Entity):
return self.roller.id
@property
def device_info(self) -> entity.DeviceInfo:
def device_info(self) -> dr.DeviceInfo:
"""Return the device info."""
return entity.DeviceInfo(
return dr.DeviceInfo(
identifiers={(DOMAIN, self.unique_id)},
manufacturer="Rollease Acmeda",
name=self.roller.name,

View File

@ -2,11 +2,11 @@
from __future__ import annotations
import asyncio
from asyncio import timeout
from contextlib import suppress
from typing import Any
import aiopulse
import async_timeout
import voluptuous as vol
from homeassistant import config_entries
@ -43,7 +43,7 @@ class AcmedaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
hubs: list[aiopulse.Hub] = []
with suppress(asyncio.TimeoutError):
async with async_timeout.timeout(5):
async with timeout(5):
async for hub in aiopulse.Hub.discover():
if hub.id not in already_configured:
hubs.append(hub)

View File

@ -23,7 +23,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import ACCOUNT_ID, CONNECTION_TYPE, DOMAIN, LOCAL

View File

@ -4,8 +4,8 @@ from __future__ import annotations
from adguardhome import AdGuardHome, AdGuardHomeError
from homeassistant.config_entries import SOURCE_HASSIO, ConfigEntry
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo, Entity
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import Entity
from .const import DATA_ADGUARD_VERSION, DOMAIN, LOGGER

View File

@ -1,12 +1,12 @@
"""Support for Automation Device Specification (ADS)."""
import asyncio
from asyncio import timeout
from collections import namedtuple
import ctypes
import logging
import struct
import threading
import async_timeout
import pyads
import voluptuous as vol
@ -301,7 +301,7 @@ class AdsEntity(Entity):
self._ads_hub.add_device_notification, ads_var, plctype, update
)
try:
async with async_timeout.timeout(10):
async with timeout(10):
await self._event.wait()
except asyncio.TimeoutError:
_LOGGER.debug("Variable %s: Timeout during first update", ads_var)

View File

@ -4,7 +4,7 @@ from typing import Any
from advantage_air import ApiError
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN

View File

@ -4,7 +4,7 @@ from typing import Any
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import ADVANTAGE_AIR_STATE_ON, DOMAIN as ADVANTAGE_AIR_DOMAIN

View File

@ -3,7 +3,7 @@
from homeassistant.components.update import UpdateEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN as ADVANTAGE_AIR_DOMAIN

View File

@ -1,11 +1,14 @@
"""The AEMET OpenData component."""
import logging
from aemet_opendata.interface import AEMET
from aemet_opendata.exceptions import TownNotFound
from aemet_opendata.interface import AEMET, ConnectionOptions
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
from .const import (
CONF_STATION_UPDATES,
@ -27,11 +30,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
longitude = entry.data[CONF_LONGITUDE]
station_updates = entry.options.get(CONF_STATION_UPDATES, True)
aemet = AEMET(api_key)
weather_coordinator = WeatherUpdateCoordinator(
hass, aemet, latitude, longitude, station_updates
)
options = ConnectionOptions(api_key, station_updates)
aemet = AEMET(aiohttp_client.async_get_clientsession(hass), options)
try:
await aemet.select_coordinates(latitude, longitude)
except TownNotFound as err:
_LOGGER.error(err)
return False
weather_coordinator = WeatherUpdateCoordinator(hass, aemet)
await weather_coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})

View File

@ -1,13 +1,14 @@
"""Config flow for AEMET OpenData."""
from __future__ import annotations
from aemet_opendata import AEMET
from aemet_opendata.exceptions import AuthError
from aemet_opendata.interface import AEMET, ConnectionOptions
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import aiohttp_client, config_validation as cv
from homeassistant.helpers.schema_config_entry_flow import (
SchemaFlowFormStep,
SchemaOptionsFlowHandler,
@ -39,8 +40,11 @@ class AemetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(f"{latitude}-{longitude}")
self._abort_if_unique_id_configured()
api_online = await _is_aemet_api_online(self.hass, user_input[CONF_API_KEY])
if not api_online:
options = ConnectionOptions(user_input[CONF_API_KEY], False)
aemet = AEMET(aiohttp_client.async_get_clientsession(self.hass), options)
try:
await aemet.select_coordinates(latitude, longitude)
except AuthError:
errors["base"] = "invalid_api_key"
if not errors:
@ -70,10 +74,3 @@ class AemetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
) -> SchemaOptionsFlowHandler:
"""Get the options flow for this handler."""
return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW)
async def _is_aemet_api_online(hass, api_key):
aemet = AEMET(api_key)
return await hass.async_add_executor_job(
aemet.get_conventional_observation_stations, False
)

View File

@ -33,6 +33,7 @@ ATTR_API_FORECAST_TEMP = "temperature"
ATTR_API_FORECAST_TEMP_LOW = "templow"
ATTR_API_FORECAST_TIME = "datetime"
ATTR_API_FORECAST_WIND_BEARING = "wind_bearing"
ATTR_API_FORECAST_WIND_MAX_SPEED = "wind_max_speed"
ATTR_API_FORECAST_WIND_SPEED = "wind_speed"
ATTR_API_HUMIDITY = "humidity"
ATTR_API_PRESSURE = "pressure"

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/aemet",
"iot_class": "cloud_polling",
"loggers": ["aemet_opendata"],
"requirements": ["AEMET-OpenData==0.2.2"]
"requirements": ["AEMET-OpenData==0.4.4"]
}

View File

@ -1,14 +1,20 @@
"""Support for the AEMET OpenData service."""
from typing import cast
from homeassistant.components.weather import (
ATTR_FORECAST_CONDITION,
ATTR_FORECAST_NATIVE_PRECIPITATION,
ATTR_FORECAST_NATIVE_TEMP,
ATTR_FORECAST_NATIVE_TEMP_LOW,
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED,
ATTR_FORECAST_NATIVE_WIND_SPEED,
ATTR_FORECAST_PRECIPITATION_PROBABILITY,
ATTR_FORECAST_TIME,
ATTR_FORECAST_WIND_BEARING,
WeatherEntity,
DOMAIN as WEATHER_DOMAIN,
Forecast,
SingleCoordinatorWeatherEntity,
WeatherEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@ -17,9 +23,9 @@ from homeassistant.const import (
UnitOfSpeed,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
ATTR_API_CONDITION,
@ -30,6 +36,7 @@ from .const import (
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,
@ -64,6 +71,7 @@ FORECAST_MAP = {
ATTR_API_FORECAST_TEMP: ATTR_FORECAST_NATIVE_TEMP,
ATTR_API_FORECAST_TIME: ATTR_FORECAST_TIME,
ATTR_API_FORECAST_WIND_BEARING: ATTR_FORECAST_WIND_BEARING,
ATTR_API_FORECAST_WIND_MAX_SPEED: ATTR_FORECAST_NATIVE_WIND_GUST_SPEED,
ATTR_API_FORECAST_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED,
},
}
@ -79,15 +87,33 @@ async def async_setup_entry(
weather_coordinator = domain_data[ENTRY_WEATHER_COORDINATOR]
entities = []
entity_registry = er.async_get(hass)
# Add daily + hourly entity for legacy config entries, only add daily for new
# config entries. This can be removed in HA Core 2024.3
if entity_registry.async_get_entity_id(
WEATHER_DOMAIN,
DOMAIN,
f"{config_entry.unique_id} {FORECAST_MODE_HOURLY}",
):
for mode in FORECAST_MODES:
name = f"{domain_data[ENTRY_NAME]} {mode}"
unique_id = f"{config_entry.unique_id} {mode}"
entities.append(AemetWeather(name, unique_id, weather_coordinator, mode))
else:
entities.append(
AemetWeather(
domain_data[ENTRY_NAME],
config_entry.unique_id,
weather_coordinator,
FORECAST_MODE_DAILY,
)
)
async_add_entities(entities, False)
class AemetWeather(CoordinatorEntity[WeatherUpdateCoordinator], WeatherEntity):
class AemetWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator]):
"""Implementation of an AEMET OpenData sensor."""
_attr_attribution = ATTRIBUTION
@ -95,6 +121,9 @@ class AemetWeather(CoordinatorEntity[WeatherUpdateCoordinator], WeatherEntity):
_attr_native_pressure_unit = UnitOfPressure.HPA
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS
_attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR
_attr_supported_features = (
WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY
)
def __init__(
self,
@ -117,15 +146,32 @@ class AemetWeather(CoordinatorEntity[WeatherUpdateCoordinator], WeatherEntity):
"""Return the current condition."""
return self.coordinator.data[ATTR_API_CONDITION]
@property
def forecast(self):
def _forecast(self, forecast_mode: str) -> list[Forecast]:
"""Return the forecast array."""
forecasts = self.coordinator.data[FORECAST_MODE_ATTR_API[self._forecast_mode]]
forecast_map = FORECAST_MAP[self._forecast_mode]
return [
forecasts = self.coordinator.data[FORECAST_MODE_ATTR_API[forecast_mode]]
forecast_map = FORECAST_MAP[forecast_mode]
return cast(
list[Forecast],
[
{ha_key: forecast[api_key] for api_key, ha_key in forecast_map.items()}
for forecast in forecasts
]
],
)
@property
def forecast(self) -> list[Forecast]:
"""Return the forecast array."""
return self._forecast(self._forecast_mode)
@callback
def _async_forecast_daily(self) -> list[Forecast]:
"""Return the daily forecast in native units."""
return self._forecast(FORECAST_MODE_DAILY)
@callback
def _async_forecast_hourly(self) -> list[Forecast]:
"""Return the hourly forecast in native units."""
return self._forecast(FORECAST_MODE_HOURLY)
@property
def humidity(self):

View File

@ -1,22 +1,21 @@
"""Weather data coordinator for the AEMET OpenData service."""
from __future__ import annotations
from dataclasses import dataclass, field
from asyncio import timeout
from datetime import timedelta
import logging
from typing import Any, Final
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_ID,
AEMET_ATTR_IDEMA,
AEMET_ATTR_MAX,
AEMET_ATTR_MIN,
AEMET_ATTR_NAME,
AEMET_ATTR_PRECIPITATION,
AEMET_ATTR_PRECIPITATION_PROBABILITY,
AEMET_ATTR_SKY_STATE,
@ -25,24 +24,24 @@ from aemet_opendata.const import (
AEMET_ATTR_SPEED,
AEMET_ATTR_STATION_DATE,
AEMET_ATTR_STATION_HUMIDITY,
AEMET_ATTR_STATION_LOCATION,
AEMET_ATTR_STATION_PRESSURE,
AEMET_ATTR_STATION_PRESSURE_SEA,
AEMET_ATTR_STATION_TEMPERATURE,
AEMET_ATTR_STORM_PROBABILITY,
AEMET_ATTR_TEMPERATURE,
AEMET_ATTR_TEMPERATURE_FEELING,
AEMET_ATTR_WIND,
AEMET_ATTR_WIND_GUST,
ATTR_DATA,
)
from aemet_opendata.exceptions import AemetError
from aemet_opendata.helpers import (
get_forecast_day_value,
get_forecast_hour_value,
get_forecast_interval_value,
)
import async_timeout
from aemet_opendata.interface import AEMET
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
@ -57,6 +56,7 @@ from .const import (
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,
@ -83,6 +83,7 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
API_TIMEOUT: Final[int] = 120
STATION_MAX_DELTA = timedelta(hours=2)
WEATHER_UPDATE_INTERVAL = timedelta(minutes=10)
@ -112,128 +113,33 @@ def format_int(value) -> int | None:
return None
class TownNotFound(UpdateFailed):
"""Raised when town is not found."""
class WeatherUpdateCoordinator(DataUpdateCoordinator):
"""Weather data update coordinator."""
def __init__(self, hass, aemet, latitude, longitude, station_updates):
def __init__(
self,
hass: HomeAssistant,
aemet: AEMET,
) -> None:
"""Initialize coordinator."""
self.aemet = aemet
super().__init__(
hass, _LOGGER, name=DOMAIN, update_interval=WEATHER_UPDATE_INTERVAL
hass,
_LOGGER,
name=DOMAIN,
update_interval=WEATHER_UPDATE_INTERVAL,
)
self._aemet = aemet
self._station = None
self._town = None
self._latitude = latitude
self._longitude = longitude
self._station_updates = station_updates
self._data = {
"daily": None,
"hourly": None,
"station": None,
}
async def _async_update_data(self):
data = {}
async with async_timeout.timeout(120):
weather_response = await self._get_aemet_weather()
data = self._convert_weather_response(weather_response)
return data
async def _get_aemet_weather(self):
"""Poll weather data from AEMET OpenData."""
weather = await self.hass.async_add_executor_job(self._get_weather_and_forecast)
return weather
def _get_weather_station(self):
if not self._station:
self._station = (
self._aemet.get_conventional_observation_station_by_coordinates(
self._latitude, self._longitude
)
)
if self._station:
_LOGGER.debug(
"station found for coordinates [%s, %s]: %s",
self._latitude,
self._longitude,
self._station,
)
if not self._station:
_LOGGER.debug(
"station not found for coordinates [%s, %s]",
self._latitude,
self._longitude,
)
return self._station
def _get_weather_town(self):
if not self._town:
self._town = self._aemet.get_town_by_coordinates(
self._latitude, self._longitude
)
if self._town:
_LOGGER.debug(
"Town found for coordinates [%s, %s]: %s",
self._latitude,
self._longitude,
self._town,
)
if not self._town:
_LOGGER.error(
"Town not found for coordinates [%s, %s]",
self._latitude,
self._longitude,
)
raise TownNotFound
return self._town
def _get_weather_and_forecast(self):
"""Get weather and forecast data from AEMET OpenData."""
self._get_weather_town()
daily = self._aemet.get_specific_forecast_town_daily(self._town[AEMET_ATTR_ID])
if not daily:
_LOGGER.error(
'Error fetching daily data for town "%s"', self._town[AEMET_ATTR_ID]
)
hourly = self._aemet.get_specific_forecast_town_hourly(
self._town[AEMET_ATTR_ID]
)
if not hourly:
_LOGGER.error(
'Error fetching hourly data for town "%s"', self._town[AEMET_ATTR_ID]
)
station = None
if self._station_updates and self._get_weather_station():
station = self._aemet.get_conventional_observation_station_data(
self._station[AEMET_ATTR_IDEMA]
)
if not station:
_LOGGER.error(
'Error fetching data for station "%s"',
self._station[AEMET_ATTR_IDEMA],
)
if daily:
self._data["daily"] = daily
if hourly:
self._data["hourly"] = hourly
if station:
self._data["station"] = station
return AemetWeather(
self._data["daily"],
self._data["hourly"],
self._data["station"],
)
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."""
@ -428,6 +334,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
),
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),
}
@ -518,14 +425,14 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
def _get_station_id(self):
"""Get station ID from weather data."""
if self._station:
return self._station[AEMET_ATTR_IDEMA]
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._station:
return self._station[AEMET_ATTR_STATION_LOCATION]
if self.aemet.station:
return self.aemet.station.get_name()
return None
@staticmethod
@ -561,19 +468,19 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
@staticmethod
def _get_temperature_feeling(day_data, hour):
"""Get temperature from weather data."""
val = get_forecast_hour_value(day_data[AEMET_ATTR_TEMPERATURE_FEELING], hour)
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._town:
return self._town[AEMET_ATTR_ID]
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._town:
return self._town[AEMET_ATTR_NAME]
if self.aemet.town:
return self.aemet.town.get_name()
return None
@staticmethod
@ -623,12 +530,3 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
if val:
return format_int(val)
return None
@dataclass
class AemetWeather:
"""Class to harmonize weather data model."""
daily: dict = field(default_factory=dict)
hourly: dict = field(default_factory=dict)
station: dict = field(default_factory=dict)

View File

@ -13,7 +13,7 @@ from homeassistant.const import (
STATE_ALARM_DISARMED,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import CONNECTION, DOMAIN as AGENT_DOMAIN

View File

@ -8,7 +8,7 @@ from homeassistant.components.camera import CameraEntityFeature
from homeassistant.components.mjpeg import MjpegCamera, filter_urllib3_logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import (
AddEntitiesCallback,
async_get_current_platform,

View File

@ -1,6 +1,7 @@
"""The Airly integration."""
from __future__ import annotations
from asyncio import timeout
from datetime import timedelta
import logging
from math import ceil
@ -9,7 +10,6 @@ from aiohttp import ClientSession
from aiohttp.client_exceptions import ClientConnectorError
from airly import Airly
from airly.exceptions import AirlyError
import async_timeout
from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM
from homeassistant.config_entries import ConfigEntry
@ -167,7 +167,7 @@ class AirlyDataUpdateCoordinator(DataUpdateCoordinator):
measurements = self.airly.create_measurements_session_point(
self.latitude, self.longitude
)
async with async_timeout.timeout(20):
async with timeout(20):
try:
await measurements.update()
except (AirlyError, ClientConnectorError) as error:

View File

@ -1,13 +1,13 @@
"""Adds config flow for Airly."""
from __future__ import annotations
from asyncio import timeout
from http import HTTPStatus
from typing import Any
from aiohttp import ClientSession
from airly import Airly
from airly.exceptions import AirlyError
import async_timeout
import voluptuous as vol
from homeassistant import config_entries
@ -105,7 +105,7 @@ async def test_location(
measurements = airly.create_measurements_session_point(
latitude=latitude, longitude=longitude
)
async with async_timeout.timeout(10):
async with timeout(10):
await measurements.update()
current = measurements.current

View File

@ -20,8 +20,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity

View File

@ -47,7 +47,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
api_key = entry.data[CONF_API_KEY]
latitude = entry.data[CONF_LATITUDE]
longitude = entry.data[CONF_LONGITUDE]
distance = entry.data[CONF_RADIUS]
# Station Radius is a user-configurable option
distance = entry.options[CONF_RADIUS]
# Reports are published hourly but update twice per hour
update_interval = datetime.timedelta(minutes=30)
@ -65,11 +67,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = coordinator
# Listen for option changes
entry.async_on_unload(entry.add_update_listener(update_listener))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate old entry."""
_LOGGER.debug("Migrating from version %s", entry.version)
if entry.version == 1:
new_options = {CONF_RADIUS: entry.data[CONF_RADIUS]}
new_data = entry.data.copy()
del new_data[CONF_RADIUS]
entry.version = 2
hass.config_entries.async_update_entry(
entry, data=new_data, options=new_options
)
_LOGGER.info("Migration to version %s successful", entry.version)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@ -80,6 +104,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return unload_ok
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
class AirNowDataUpdateCoordinator(DataUpdateCoordinator):
"""Define an object to hold Airly data."""

View File

@ -1,11 +1,12 @@
"""Config flow for AirNow integration."""
import logging
from typing import Any
from pyairnow import WebServiceAPI
from pyairnow.errors import AirNowError, EmptyResponseError, InvalidKeyError
import voluptuous as vol
from homeassistant import config_entries, core, exceptions
from homeassistant import config_entries, core, data_entry_flow, exceptions
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
@ -48,7 +49,7 @@ async def validate_input(hass: core.HomeAssistant, data):
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for AirNow."""
VERSION = 1
VERSION = 2
async def async_step_user(self, user_input=None):
"""Handle the initial step."""
@ -75,12 +76,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors["base"] = "unknown"
else:
# Create Entry
radius = user_input.pop(CONF_RADIUS)
return self.async_create_entry(
title=(
f"AirNow Sensor at {user_input[CONF_LATITUDE]},"
f" {user_input[CONF_LONGITUDE]}"
),
data=user_input,
options={CONF_RADIUS: radius},
)
return self.async_show_form(
@ -94,12 +97,49 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
vol.Optional(
CONF_LONGITUDE, default=self.hass.config.longitude
): cv.longitude,
vol.Optional(CONF_RADIUS, default=150): int,
vol.Optional(CONF_RADIUS, default=150): vol.All(
int, vol.Range(min=5)
),
}
),
errors=errors,
)
@staticmethod
@core.callback
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> config_entries.OptionsFlow:
"""Return the options flow."""
return OptionsFlowHandler(config_entry)
class OptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry):
"""Handle an options flow for AirNow."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> data_entry_flow.FlowResult:
"""Manage the options."""
if user_input is not None:
return self.async_create_entry(data=user_input)
options_schema = vol.Schema(
{
vol.Optional(CONF_RADIUS): vol.All(
int,
vol.Range(min=5),
),
}
)
return self.async_show_form(
step_id="init",
data_schema=self.add_suggested_values_to_schema(
options_schema, self.config_entry.options
),
)
class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect."""

View File

@ -17,8 +17,7 @@ from homeassistant.const import (
CONCENTRATION_PARTS_PER_MILLION,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@ -30,6 +29,9 @@ from .const import (
ATTR_API_AQI_LEVEL,
ATTR_API_O3,
ATTR_API_PM25,
ATTR_API_STATION,
ATTR_API_STATION_LATITUDE,
ATTR_API_STATION_LONGITUDE,
DEFAULT_NAME,
DOMAIN,
)
@ -40,6 +42,7 @@ PARALLEL_UPDATES = 1
ATTR_DESCR = "description"
ATTR_LEVEL = "level"
ATTR_STATION = "reporting_station"
@dataclass
@ -85,6 +88,16 @@ SENSOR_TYPES: tuple[AirNowEntityDescription, ...] = (
value_fn=lambda data: data.get(ATTR_API_O3),
extra_state_attributes_fn=None,
),
AirNowEntityDescription(
key=ATTR_API_STATION,
translation_key="station",
icon="mdi:blur",
value_fn=lambda data: data.get(ATTR_API_STATION),
extra_state_attributes_fn=lambda data: {
"lat": data[ATTR_API_STATION_LATITUDE],
"long": data[ATTR_API_STATION_LONGITUDE],
},
),
)

View File

@ -21,10 +21,26 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"options": {
"step": {
"init": {
"data": {
"radius": "Station Radius (miles)"
}
}
}
},
"entity": {
"sensor": {
"o3": {
"name": "[%key:component::sensor::entity_component::ozone::name%]"
},
"station": {
"name": "PM2.5 reporting station",
"state_attributes": {
"lat": { "name": "[%key:common::config_flow::data::latitude%]" },
"long": { "name": "[%key:common::config_flow::data::longitude%]" }
}
}
}
}

View File

@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN, MANUFACTURER, TARGET_ROUTE, UPDATE_INTERVAL

View File

@ -21,7 +21,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
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 (

View File

@ -34,8 +34,12 @@ class Discovery:
def get_name(device: AirthingsDevice) -> str:
"""Generate name with identifier for device."""
return f"{device.name} ({device.identifier})"
"""Generate name with model and identifier for device."""
name = device.friendly_name()
if identifier := device.identifier:
name += f" ({identifier})"
return name
class AirthingsDeviceUpdateError(Exception):
@ -156,7 +160,7 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="no_devices_found")
titles = {
address: get_name(discovery.device)
address: discovery.device.name
for (address, discovery) in self._discovered_devices.items()
}
return self.async_show_form(

View File

@ -24,5 +24,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/airthings_ble",
"iot_class": "local_polling",
"requirements": ["airthings-ble==0.5.3"]
"requirements": ["airthings-ble==0.5.6-2"]
}

View File

@ -22,8 +22,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import (
@ -162,11 +161,11 @@ class AirthingsSensor(
super().__init__(coordinator)
self.entity_description = entity_description
name = f"{airthings_device.name} {airthings_device.identifier}"
name = airthings_device.name
if identifier := airthings_device.identifier:
name += f" ({identifier})"
self._attr_unique_id = f"{name}_{entity_description.key}"
self._id = airthings_device.address
self._attr_device_info = DeviceInfo(
connections={
(
@ -175,9 +174,10 @@ class AirthingsSensor(
)
},
name=name,
manufacturer="Airthings",
manufacturer=airthings_device.manufacturer,
hw_version=airthings_device.hw_version,
sw_version=airthings_device.sw_version,
model=airthings_device.model,
)
@property

View File

@ -18,7 +18,7 @@ from homeassistant.components.climate import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@ -98,28 +98,20 @@ class AirtouchAC(CoordinatorEntity, ClimateEntity):
self._ac_number = ac_number
self._airtouch = coordinator.airtouch
self._info = info
self._unit = self._airtouch.GetAcs()[self._ac_number]
self._unit = self._airtouch.GetAcs()[ac_number]
self._attr_unique_id = f"ac_{ac_number}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"ac_{ac_number}")},
name=f"AC {ac_number}",
manufacturer="Airtouch",
model="Airtouch 4",
)
@callback
def _handle_coordinator_update(self):
self._unit = self._airtouch.GetAcs()[self._ac_number]
return super()._handle_coordinator_update()
@property
def device_info(self) -> DeviceInfo:
"""Return device info for this device."""
return DeviceInfo(
identifiers={(DOMAIN, self.unique_id)},
name=f"AC {self._ac_number}",
manufacturer="Airtouch",
model="Airtouch 4",
)
@property
def unique_id(self):
"""Return unique ID for this device."""
return f"ac_{self._ac_number}"
@property
def current_temperature(self):
"""Return the current temperature."""
@ -208,29 +200,21 @@ class AirtouchGroup(CoordinatorEntity, ClimateEntity):
"""Initialize the climate device."""
super().__init__(coordinator)
self._group_number = group_number
self._attr_unique_id = group_number
self._airtouch = coordinator.airtouch
self._info = info
self._unit = self._airtouch.GetGroupByGroupNumber(self._group_number)
@callback
def _handle_coordinator_update(self):
self._unit = self._airtouch.GetGroupByGroupNumber(self._group_number)
return super()._handle_coordinator_update()
@property
def device_info(self) -> DeviceInfo:
"""Return device info for this device."""
return DeviceInfo(
identifiers={(DOMAIN, self.unique_id)},
self._unit = self._airtouch.GetGroupByGroupNumber(group_number)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, group_number)},
manufacturer="Airtouch",
model="Airtouch 4",
name=self._unit.GroupName,
)
@property
def unique_id(self):
"""Return unique ID for this device."""
return self._group_number
@callback
def _handle_coordinator_update(self):
self._unit = self._airtouch.GetGroupByGroupNumber(self._group_number)
return super()._handle_coordinator_update()
@property
def min_temp(self):

View File

@ -1,7 +1,7 @@
{
"domain": "airtouch4",
"name": "AirTouch 4",
"codeowners": ["@LonePurpleWolf"],
"codeowners": [],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/airtouch4",
"iot_class": "local_polling",

View File

@ -109,19 +109,21 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"airvisual_checked_api_keys_lock", asyncio.Lock()
)
async with valid_keys_lock:
if user_input[CONF_API_KEY] not in valid_keys:
if integration_type == INTEGRATION_TYPE_GEOGRAPHY_COORDS:
coro = cloud_api.air_quality.nearest_city()
error_schema = self.geography_coords_schema
error_step = "geography_by_coords"
else:
coro = cloud_api.air_quality.city(
user_input[CONF_CITY], user_input[CONF_STATE], user_input[CONF_COUNTRY]
user_input[CONF_CITY],
user_input[CONF_STATE],
user_input[CONF_COUNTRY],
)
error_schema = GEOGRAPHY_NAME_SCHEMA
error_step = "geography_by_name"
async with valid_keys_lock:
if user_input[CONF_API_KEY] not in valid_keys:
try:
await coro
except (InvalidKeyError, KeyExpiredError, UnauthorizedError):

View File

@ -23,7 +23,8 @@ from homeassistant.const import (
)
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.entity import DeviceInfo, EntityDescription
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,

View File

@ -1,13 +1,13 @@
"""The Airzone integration."""
from __future__ import annotations
from asyncio import timeout
from datetime import timedelta
import logging
from typing import Any
from aioairzone.exceptions import AirzoneError
from aioairzone.localapi import AirzoneLocalApi
import async_timeout
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@ -35,7 +35,7 @@ class AirzoneUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
async def _async_update_data(self) -> dict[str, Any]:
"""Update data via library."""
async with async_timeout.timeout(AIOAIRZONE_DEVICE_TIMEOUT_SEC):
async with timeout(AIOAIRZONE_DEVICE_TIMEOUT_SEC):
try:
await self.airzone.update()
except AirzoneError as error:

View File

@ -10,6 +10,7 @@ from aioairzone.const import (
AZD_AVAILABLE,
AZD_FIRMWARE,
AZD_FULL_NAME,
AZD_HOT_WATER,
AZD_ID,
AZD_MAC,
AZD_MODEL,
@ -26,7 +27,7 @@ from aioairzone.exceptions import AirzoneError
from homeassistant.config_entries import ConfigEntry
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
@ -81,6 +82,31 @@ class AirzoneSystemEntity(AirzoneEntity):
return value
class AirzoneHotWaterEntity(AirzoneEntity):
"""Define an Airzone Hot Water entity."""
def __init__(
self,
coordinator: AirzoneUpdateCoordinator,
entry: ConfigEntry,
) -> None:
"""Initialize."""
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{entry.entry_id}_dhw")},
manufacturer=MANUFACTURER,
model="DHW",
name=self.get_airzone_value(AZD_NAME),
via_device=(DOMAIN, f"{entry.entry_id}_ws"),
)
self._attr_unique_id = entry.unique_id or entry.entry_id
def get_airzone_value(self, key: str) -> Any:
"""Return DHW value by key."""
return self.coordinator.data[AZD_HOT_WATER].get(key)
class AirzoneWebServerEntity(AirzoneEntity):
"""Define an Airzone WebServer entity."""

View File

@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone",
"iot_class": "local_polling",
"loggers": ["aioairzone"],
"requirements": ["aioairzone==0.6.5"]
"requirements": ["aioairzone==0.6.8"]
}

View File

@ -4,6 +4,7 @@ from __future__ import annotations
from typing import Any, Final
from aioairzone.const import (
AZD_HOT_WATER,
AZD_HUMIDITY,
AZD_NAME,
AZD_TEMP,
@ -31,7 +32,21 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, TEMP_UNIT_LIB_TO_HASS
from .coordinator import AirzoneUpdateCoordinator
from .entity import AirzoneEntity, AirzoneWebServerEntity, AirzoneZoneEntity
from .entity import (
AirzoneEntity,
AirzoneHotWaterEntity,
AirzoneWebServerEntity,
AirzoneZoneEntity,
)
HOT_WATER_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = (
SensorEntityDescription(
device_class=SensorDeviceClass.TEMPERATURE,
key=AZD_TEMP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
),
)
WEBSERVER_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = (
SensorEntityDescription(
@ -71,6 +86,18 @@ async def async_setup_entry(
sensors: list[AirzoneSensor] = []
if AZD_HOT_WATER in coordinator.data:
dhw_data = coordinator.data[AZD_HOT_WATER]
for description in HOT_WATER_SENSOR_TYPES:
if description.key in dhw_data:
sensors.append(
AirzoneHotWaterSensor(
coordinator,
description,
entry,
)
)
if AZD_WEBSERVER in coordinator.data:
ws_data = coordinator.data[AZD_WEBSERVER]
for description in WEBSERVER_SENSOR_TYPES:
@ -114,6 +141,30 @@ class AirzoneSensor(AirzoneEntity, SensorEntity):
self._attr_native_value = self.get_airzone_value(self.entity_description.key)
class AirzoneHotWaterSensor(AirzoneHotWaterEntity, AirzoneSensor):
"""Define an Airzone Hot Water sensor."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: AirzoneUpdateCoordinator,
description: SensorEntityDescription,
entry: ConfigEntry,
) -> None:
"""Initialize."""
super().__init__(coordinator, entry)
self._attr_unique_id = f"{self._attr_unique_id}_dhw_{description.key}"
self.entity_description = description
self._attr_native_unit_of_measurement = TEMP_UNIT_LIB_TO_HASS.get(
self.get_airzone_value(AZD_TEMP_UNIT)
)
self._async_update_attrs()
class AirzoneWebServerSensor(AirzoneWebServerEntity, AirzoneSensor):
"""Define an Airzone WebServer sensor."""

View File

@ -9,6 +9,7 @@ from aioairzone_cloud.const import (
AZD_AIDOOS,
AZD_ERRORS,
AZD_PROBLEMS,
AZD_SYSTEMS,
AZD_WARNINGS,
AZD_ZONES,
)
@ -25,7 +26,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import AirzoneUpdateCoordinator
from .entity import AirzoneAidooEntity, AirzoneEntity, AirzoneZoneEntity
from .entity import (
AirzoneAidooEntity,
AirzoneEntity,
AirzoneSystemEntity,
AirzoneZoneEntity,
)
@dataclass
@ -51,6 +57,20 @@ AIDOO_BINARY_SENSOR_TYPES: Final[tuple[AirzoneBinarySensorEntityDescription, ...
),
)
SYSTEM_BINARY_SENSOR_TYPES: Final[tuple[AirzoneBinarySensorEntityDescription, ...]] = (
AirzoneBinarySensorEntityDescription(
attributes={
"errors": AZD_ERRORS,
"warnings": AZD_WARNINGS,
},
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
key=AZD_PROBLEMS,
),
)
ZONE_BINARY_SENSOR_TYPES: Final[tuple[AirzoneBinarySensorEntityDescription, ...]] = (
AirzoneBinarySensorEntityDescription(
device_class=BinarySensorDeviceClass.RUNNING,
@ -87,6 +107,18 @@ async def async_setup_entry(
)
)
for system_id, system_data in coordinator.data.get(AZD_SYSTEMS, {}).items():
for description in SYSTEM_BINARY_SENSOR_TYPES:
if description.key in system_data:
binary_sensors.append(
AirzoneSystemBinarySensor(
coordinator,
description,
system_id,
system_data,
)
)
for zone_id, zone_data in coordinator.data.get(AZD_ZONES, {}).items():
for description in ZONE_BINARY_SENSOR_TYPES:
if description.key in zone_data:
@ -145,6 +177,27 @@ class AirzoneAidooBinarySensor(AirzoneAidooEntity, AirzoneBinarySensor):
self._async_update_attrs()
class AirzoneSystemBinarySensor(AirzoneSystemEntity, AirzoneBinarySensor):
"""Define an Airzone Cloud System binary sensor."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: AirzoneUpdateCoordinator,
description: AirzoneBinarySensorEntityDescription,
system_id: str,
system_data: dict[str, Any],
) -> None:
"""Initialize."""
super().__init__(coordinator, system_id, system_data)
self._attr_unique_id = f"{system_id}_{description.key}"
self.entity_description = description
self._async_update_attrs()
class AirzoneZoneBinarySensor(AirzoneZoneEntity, AirzoneBinarySensor):
"""Define an Airzone Cloud Zone binary sensor."""

View File

@ -1,13 +1,13 @@
"""The Airzone Cloud integration coordinator."""
from __future__ import annotations
from asyncio import timeout
from datetime import timedelta
import logging
from typing import Any
from aioairzone_cloud.cloudapi import AirzoneCloudApi
from aioairzone_cloud.exceptions import AirzoneCloudError
import async_timeout
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@ -35,7 +35,7 @@ class AirzoneUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
async def _async_update_data(self) -> dict[str, Any]:
"""Update data via library."""
async with async_timeout.timeout(AIOAIRZONE_CLOUD_TIMEOUT_SEC):
async with timeout(AIOAIRZONE_CLOUD_TIMEOUT_SEC):
try:
await self.airzone.update()
except AirzoneCloudError as error:

View File

@ -10,13 +10,14 @@ from aioairzone_cloud.const import (
AZD_FIRMWARE,
AZD_NAME,
AZD_SYSTEM_ID,
AZD_SYSTEMS,
AZD_WEBSERVER,
AZD_WEBSERVERS,
AZD_ZONES,
)
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
@ -65,6 +66,35 @@ class AirzoneAidooEntity(AirzoneEntity):
return value
class AirzoneSystemEntity(AirzoneEntity):
"""Define an Airzone Cloud System entity."""
def __init__(
self,
coordinator: AirzoneUpdateCoordinator,
system_id: str,
system_data: dict[str, Any],
) -> None:
"""Initialize."""
super().__init__(coordinator)
self.system_id = system_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, system_id)},
manufacturer=MANUFACTURER,
name=system_data[AZD_NAME],
via_device=(DOMAIN, system_data[AZD_WEBSERVER]),
)
def get_airzone_value(self, key: str) -> Any:
"""Return system value by key."""
value = None
if system := self.coordinator.data[AZD_SYSTEMS].get(self.system_id):
value = system.get(key)
return value
class AirzoneWebServerEntity(AirzoneEntity):
"""Define an Airzone Cloud WebServer entity."""

View File

@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPENING
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, STATES_MAP, SUPPORTED_FEATURES

View File

@ -16,7 +16,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN

View File

@ -16,8 +16,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import dispatcher_send
from homeassistant.helpers.event import async_track_point_in_time
from homeassistant.util import dt as dt_util
from homeassistant.helpers.event import async_call_later
from .const import (
CONF_DEVICE_BAUD,
@ -66,9 +65,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hass.async_add_executor_job(controller.open, baud)
except NoDeviceError:
_LOGGER.debug("Failed to connect. Retrying in 5 seconds")
async_track_point_in_time(
hass, open_connection, dt_util.utcnow() + timedelta(seconds=5)
)
async_call_later(hass, timedelta(seconds=5), open_connection)
return
_LOGGER.debug("Established a connection with the alarmdecoder")
hass.data[DOMAIN][entry.entry_id][DATA_RESTART] = True

View File

@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entityfilter
from homeassistant.helpers.typing import ConfigType
from . import flash_briefings, intent, smart_home_http
from . import flash_briefings, intent, smart_home
from .const import (
CONF_AUDIO,
CONF_DISPLAY_CATEGORIES,
@ -100,6 +100,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
if CONF_SMART_HOME in config:
smart_home_config: dict[str, Any] | None = config[CONF_SMART_HOME]
smart_home_config = smart_home_config or SMART_HOME_SCHEMA({})
await smart_home_http.async_setup(hass, smart_home_config)
await smart_home.async_setup(hass, smart_home_config)
return True

View File

@ -1,15 +1,16 @@
"""Support for Alexa skill auth."""
import asyncio
from datetime import timedelta
from asyncio import timeout
from datetime import datetime, timedelta
from http import HTTPStatus
import json
import logging
from typing import Any
import aiohttp
import async_timeout
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
from homeassistant.core import callback
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.storage import Store
from homeassistant.util import dt as dt_util
@ -30,24 +31,24 @@ STORAGE_REFRESH_TOKEN = "refresh_token"
class Auth:
"""Handle authentication to send events to Alexa."""
def __init__(self, hass, client_id, client_secret):
def __init__(self, hass: HomeAssistant, client_id: str, client_secret: str) -> None:
"""Initialize the Auth class."""
self.hass = hass
self.client_id = client_id
self.client_secret = client_secret
self._prefs = None
self._store = Store(hass, STORAGE_VERSION, STORAGE_KEY)
self._prefs: dict[str, Any] | None = None
self._store: Store = Store(hass, STORAGE_VERSION, STORAGE_KEY)
self._get_token_lock = asyncio.Lock()
async def async_do_auth(self, accept_grant_code):
async def async_do_auth(self, accept_grant_code: str) -> str | None:
"""Do authentication with an AcceptGrant code."""
# access token not retrieved yet for the first time, so this should
# be an access token request
lwa_params = {
lwa_params: dict[str, str] = {
"grant_type": "authorization_code",
"code": accept_grant_code,
CONF_CLIENT_ID: self.client_id,
@ -61,25 +62,28 @@ class Auth:
return await self._async_request_new_token(lwa_params)
@callback
def async_invalidate_access_token(self):
def async_invalidate_access_token(self) -> None:
"""Invalidate access token."""
assert self._prefs is not None
self._prefs[STORAGE_ACCESS_TOKEN] = None
async def async_get_access_token(self):
async def async_get_access_token(self) -> str | None:
"""Perform access token or token refresh request."""
async with self._get_token_lock:
if self._prefs is None:
await self.async_load_preferences()
assert self._prefs is not None
if self.is_token_valid():
_LOGGER.debug("Token still valid, using it")
return self._prefs[STORAGE_ACCESS_TOKEN]
token: str = self._prefs[STORAGE_ACCESS_TOKEN]
return token
if self._prefs[STORAGE_REFRESH_TOKEN] is None:
_LOGGER.debug("Token invalid and no refresh token available")
return None
lwa_params = {
lwa_params: dict[str, str] = {
"grant_type": "refresh_token",
"refresh_token": self._prefs[STORAGE_REFRESH_TOKEN],
CONF_CLIENT_ID: self.client_id,
@ -90,22 +94,26 @@ class Auth:
return await self._async_request_new_token(lwa_params)
@callback
def is_token_valid(self):
def is_token_valid(self) -> bool:
"""Check if a token is already loaded and if it is still valid."""
assert self._prefs is not None
if not self._prefs[STORAGE_ACCESS_TOKEN]:
return False
expire_time = dt_util.parse_datetime(self._prefs[STORAGE_EXPIRE_TIME])
expire_time: datetime | None = dt_util.parse_datetime(
self._prefs[STORAGE_EXPIRE_TIME]
)
assert expire_time is not None
preemptive_expire_time = expire_time - timedelta(
seconds=PREEMPTIVE_REFRESH_TTL_IN_SECONDS
)
return dt_util.utcnow() < preemptive_expire_time
async def _async_request_new_token(self, lwa_params):
async def _async_request_new_token(self, lwa_params: dict[str, str]) -> str | None:
try:
session = aiohttp_client.async_get_clientsession(self.hass)
async with async_timeout.timeout(10):
async with timeout(10):
response = await session.post(
LWA_TOKEN_URI,
headers=LWA_HEADERS,
@ -127,9 +135,9 @@ class Auth:
response_json = await response.json()
_LOGGER.debug("LWA response body : %s", response_json)
access_token = response_json["access_token"]
refresh_token = response_json["refresh_token"]
expires_in = response_json["expires_in"]
access_token: str = response_json["access_token"]
refresh_token: str = response_json["refresh_token"]
expires_in: int = response_json["expires_in"]
expire_time = dt_util.utcnow() + timedelta(seconds=expires_in)
await self._async_update_preferences(
@ -138,7 +146,7 @@ class Auth:
return access_token
async def async_load_preferences(self):
async def async_load_preferences(self) -> None:
"""Load preferences with stored tokens."""
self._prefs = await self._store.async_load()
@ -149,10 +157,13 @@ class Auth:
STORAGE_EXPIRE_TIME: None,
}
async def _async_update_preferences(self, access_token, refresh_token, expire_time):
async def _async_update_preferences(
self, access_token: str, refresh_token: str, expire_time: str
) -> None:
"""Update user preferences."""
if self._prefs is None:
await self.async_load_preferences()
assert self._prefs is not None
if access_token is not None:
self._prefs[STORAGE_ACCESS_TOKEN] = access_token

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,9 @@ from __future__ import annotations
from abc import ABC, abstractmethod
import asyncio
import logging
from typing import Any
from yarl import URL
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.storage import Store
@ -33,38 +36,38 @@ class AbstractConfig(ABC):
await self._store.async_load()
@property
def supports_auth(self):
def supports_auth(self) -> bool:
"""Return if config supports auth."""
return False
@property
def should_report_state(self):
def should_report_state(self) -> bool:
"""Return if states should be proactively reported."""
return False
@property
def endpoint(self):
@abstractmethod
def endpoint(self) -> str | URL | None:
"""Endpoint for report state."""
return None
@property
@abstractmethod
def locale(self):
def locale(self) -> str | None:
"""Return config locale."""
@property
def entity_config(self):
def entity_config(self) -> dict[str, Any]:
"""Return entity config."""
return {}
@property
def is_reporting_states(self):
def is_reporting_states(self) -> bool:
"""Return if proactive mode is enabled."""
return self._unsub_proactive_report is not None
@callback
@abstractmethod
def user_identifier(self):
def user_identifier(self) -> str:
"""Return an identifier for the user that represents this config."""
async def async_enable_proactive_mode(self) -> None:
@ -85,29 +88,29 @@ class AbstractConfig(ABC):
self._unsub_proactive_report = None
@callback
def should_expose(self, entity_id):
def should_expose(self, entity_id: str) -> bool:
"""If an entity should be exposed."""
return False
@callback
def async_invalidate_access_token(self):
def async_invalidate_access_token(self) -> None:
"""Invalidate access token."""
raise NotImplementedError
async def async_get_access_token(self):
async def async_get_access_token(self) -> str | None:
"""Get an access token."""
raise NotImplementedError
async def async_accept_grant(self, code):
async def async_accept_grant(self, code: str) -> str | None:
"""Accept a grant."""
raise NotImplementedError
@property
def authorized(self):
def authorized(self) -> bool:
"""Return authorization status."""
return self._store.authorized
async def set_authorized(self, authorized) -> None:
async def set_authorized(self, authorized: bool) -> None:
"""Set authorization status.
- Set when an incoming message is received from Alexa.
@ -132,25 +135,26 @@ class AlexaConfigStore:
_STORAGE_VERSION = 1
_STORAGE_KEY = DOMAIN
def __init__(self, hass):
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize a configuration store."""
self._data = None
self._data: dict[str, Any] | None = None
self._hass = hass
self._store = Store(hass, self._STORAGE_VERSION, self._STORAGE_KEY)
self._store: Store = Store(hass, self._STORAGE_VERSION, self._STORAGE_KEY)
@property
def authorized(self):
def authorized(self) -> bool:
"""Return authorization status."""
return self._data[STORE_AUTHORIZED]
assert self._data is not None
return bool(self._data[STORE_AUTHORIZED])
@callback
def set_authorized(self, authorized):
def set_authorized(self, authorized: bool) -> None:
"""Set authorization status."""
if authorized != self._data[STORE_AUTHORIZED]:
if self._data is not None and authorized != self._data[STORE_AUTHORIZED]:
self._data[STORE_AUTHORIZED] = authorized
self._store.async_delay_save(lambda: self._data, 1.0)
async def async_load(self):
async def async_load(self) -> None:
"""Load saved configuration from disk."""
if data := await self._store.async_load():
self._data = data

View File

@ -69,7 +69,7 @@ API_TEMP_UNITS = {
# Needs to be ordered dict for `async_api_set_thermostat_mode` which does a
# reverse mapping of this dict and we want to map the first occurrence of OFF
# back to HA state.
API_THERMOSTAT_MODES = OrderedDict(
API_THERMOSTAT_MODES: OrderedDict[str, str] = OrderedDict(
[
(climate.HVACMode.HEAT, "HEAT"),
(climate.HVACMode.COOL, "COOL"),

View File

@ -3,7 +3,7 @@ from __future__ import annotations
from collections.abc import Generator, Iterable
import logging
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from homeassistant.components import (
alarm_control_panel,
@ -274,22 +274,23 @@ class AlexaEntity:
self.entity_conf = config.entity_config.get(entity.entity_id, {})
@property
def entity_id(self):
def entity_id(self) -> str:
"""Return the Entity ID."""
return self.entity.entity_id
def friendly_name(self):
def friendly_name(self) -> str:
"""Return the Alexa API friendly name."""
return self.entity_conf.get(CONF_NAME, self.entity.name).translate(
TRANSLATION_TABLE
)
friendly_name: str = self.entity_conf.get(
CONF_NAME, self.entity.name
).translate(TRANSLATION_TABLE)
return friendly_name
def description(self):
def description(self) -> str:
"""Return the Alexa API description."""
description = self.entity_conf.get(CONF_DESCRIPTION) or self.entity_id
return f"{description} via Home Assistant".translate(TRANSLATION_TABLE)
def alexa_id(self):
def alexa_id(self) -> str:
"""Return the Alexa API entity id."""
return generate_alexa_id(self.entity.entity_id)
@ -317,7 +318,7 @@ class AlexaEntity:
"""
raise NotImplementedError
def serialize_properties(self):
def serialize_properties(self) -> Generator[dict[str, Any], None, None]:
"""Yield each supported property in API format."""
for interface in self.interfaces():
if not interface.properties_proactively_reported():
@ -325,9 +326,9 @@ class AlexaEntity:
yield from interface.serialize_properties()
def serialize_discovery(self):
def serialize_discovery(self) -> dict[str, Any]:
"""Serialize the entity for discovery."""
result = {
result: dict[str, Any] = {
"displayCategories": self.display_categories(),
"cookie": {},
"endpointId": self.alexa_id(),
@ -366,7 +367,7 @@ def async_get_entities(
hass: HomeAssistant, config: AbstractConfig
) -> list[AlexaEntity]:
"""Return all entities that are supported by Alexa."""
entities = []
entities: list[AlexaEntity] = []
for state in hass.states.async_all():
if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
continue
@ -725,7 +726,7 @@ class MediaPlayerCapabilities(AlexaEntity):
class SceneCapabilities(AlexaEntity):
"""Class to represent Scene capabilities."""
def description(self):
def description(self) -> str:
"""Return the Alexa API description."""
description = AlexaEntity.description(self)
if "scene" not in description.casefold():

View File

@ -1,8 +1,9 @@
"""Alexa related errors."""
from __future__ import annotations
from typing import Literal
from typing import Any, Literal
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from .const import API_TEMP_UNITS
@ -29,7 +30,9 @@ class AlexaError(Exception):
namespace: str | None = None
error_type: str | None = None
def __init__(self, error_message, payload=None):
def __init__(
self, error_message: str, payload: dict[str, Any] | None = None
) -> None:
"""Initialize an alexa error."""
Exception.__init__(self)
self.error_message = error_message
@ -42,7 +45,7 @@ class AlexaInvalidEndpointError(AlexaError):
namespace = "Alexa"
error_type = "NO_SUCH_ENDPOINT"
def __init__(self, endpoint_id):
def __init__(self, endpoint_id: str) -> None:
"""Initialize invalid endpoint error."""
msg = f"The endpoint {endpoint_id} does not exist"
AlexaError.__init__(self, msg)
@ -93,7 +96,9 @@ class AlexaTempRangeError(AlexaError):
namespace = "Alexa"
error_type = "TEMPERATURE_VALUE_OUT_OF_RANGE"
def __init__(self, hass, temp, min_temp, max_temp):
def __init__(
self, hass: HomeAssistant, temp: float, min_temp: float, max_temp: float
) -> None:
"""Initialize TempRange error."""
unit = hass.config.units.temperature_unit
temp_range = {

View File

@ -4,10 +4,13 @@ from http import HTTPStatus
import logging
import uuid
from aiohttp.web_response import StreamResponse
from homeassistant.components import http
from homeassistant.const import CONF_PASSWORD
from homeassistant.core import callback
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import template
from homeassistant.helpers.typing import ConfigType
import homeassistant.util.dt as dt_util
from .const import (
@ -32,7 +35,7 @@ FLASH_BRIEFINGS_API_ENDPOINT = "/api/alexa/flash_briefings/{briefing_id}"
@callback
def async_setup(hass, flash_briefing_config):
def async_setup(hass: HomeAssistant, flash_briefing_config: ConfigType) -> None:
"""Activate Alexa component."""
hass.http.register_view(AlexaFlashBriefingView(hass, flash_briefing_config))
@ -44,14 +47,16 @@ class AlexaFlashBriefingView(http.HomeAssistantView):
requires_auth = False
name = "api:alexa:flash_briefings"
def __init__(self, hass, flash_briefings):
def __init__(self, hass: HomeAssistant, flash_briefings: ConfigType) -> None:
"""Initialize Alexa view."""
super().__init__()
self.flash_briefings = flash_briefings
template.attach(hass, self.flash_briefings)
@callback
def get(self, request, briefing_id):
def get(
self, request: http.HomeAssistantRequest, briefing_id: str
) -> StreamResponse | tuple[bytes, HTTPStatus]:
"""Handle Alexa Flash Briefing request."""
_LOGGER.debug("Received Alexa flash briefing request for: %s", briefing_id)

View File

@ -75,8 +75,7 @@ from .errors import (
AlexaUnsupportedThermostatModeError,
AlexaVideoActionNotPermittedForContentError,
)
from .messages import AlexaDirective, AlexaResponse
from .state_report import async_enable_proactive_mode
from .state_report import AlexaDirective, AlexaResponse, async_enable_proactive_mode
_LOGGER = logging.getLogger(__name__)
DIRECTIVE_NOT_SUPPORTED = "Entity does not support directive"
@ -124,7 +123,7 @@ async def async_api_accept_grant(
Async friendly.
"""
auth_code = directive.payload["grant"]["code"]
auth_code: str = directive.payload["grant"]["code"]
_LOGGER.debug("AcceptGrant code: %s", auth_code)
if config.supports_auth:
@ -340,8 +339,8 @@ async def async_api_decrease_color_temp(
) -> AlexaResponse:
"""Process a decrease color temperature request."""
entity = directive.entity
current = int(entity.attributes.get(light.ATTR_COLOR_TEMP))
max_mireds = int(entity.attributes.get(light.ATTR_MAX_MIREDS))
current = int(entity.attributes[light.ATTR_COLOR_TEMP])
max_mireds = int(entity.attributes[light.ATTR_MAX_MIREDS])
value = min(max_mireds, current + 50)
await hass.services.async_call(
@ -364,8 +363,8 @@ async def async_api_increase_color_temp(
) -> AlexaResponse:
"""Process an increase color temperature request."""
entity = directive.entity
current = int(entity.attributes.get(light.ATTR_COLOR_TEMP))
min_mireds = int(entity.attributes.get(light.ATTR_MIN_MIREDS))
current = int(entity.attributes[light.ATTR_COLOR_TEMP])
min_mireds = int(entity.attributes[light.ATTR_MIN_MIREDS])
value = max(min_mireds, current - 50)
await hass.services.async_call(
@ -404,7 +403,7 @@ async def async_api_activate(
context=context,
)
payload = {
payload: dict[str, Any] = {
"cause": {"type": Cause.VOICE_INTERACTION},
"timestamp": dt_util.utcnow().strftime(DATE_FORMAT),
}
@ -433,7 +432,7 @@ async def async_api_deactivate(
context=context,
)
payload = {
payload: dict[str, Any] = {
"cause": {"type": Cause.VOICE_INTERACTION},
"timestamp": dt_util.utcnow().strftime(DATE_FORMAT),
}
@ -475,7 +474,24 @@ async def async_api_unlock(
context: ha.Context,
) -> AlexaResponse:
"""Process an unlock request."""
if config.locale not in {"de-DE", "en-US", "ja-JP"}:
if config.locale not in {
"ar-SA",
"de-DE",
"en-AU",
"en-CA",
"en-GB",
"en-IN",
"en-US",
"es-ES",
"es-MX",
"es-US",
"fr-CA",
"fr-FR",
"hi-IN",
"it-IT",
"ja-JP",
"pt-BR",
}:
msg = (
"The unlock directive is not supported for the following locales:"
f" {config.locale}"
@ -510,7 +526,7 @@ async def async_api_set_volume(
volume = round(float(directive.payload["volume"] / 100), 2)
entity = directive.entity
data = {
data: dict[str, Any] = {
ATTR_ENTITY_ID: entity.entity_id,
media_player.const.ATTR_MEDIA_VOLUME_LEVEL: volume,
}
@ -555,7 +571,7 @@ async def async_api_select_input(
)
raise AlexaInvalidValueError(msg)
data = {
data: dict[str, Any] = {
ATTR_ENTITY_ID: entity.entity_id,
media_player.const.ATTR_INPUT_SOURCE: media_input,
}
@ -582,7 +598,7 @@ async def async_api_adjust_volume(
volume_delta = int(directive.payload["volume"])
entity = directive.entity
current_level = entity.attributes.get(media_player.const.ATTR_MEDIA_VOLUME_LEVEL)
current_level = entity.attributes[media_player.const.ATTR_MEDIA_VOLUME_LEVEL]
# read current state
try:
@ -592,7 +608,7 @@ async def async_api_adjust_volume(
volume = float(max(0, volume_delta + current) / 100)
data = {
data: dict[str, Any] = {
ATTR_ENTITY_ID: entity.entity_id,
media_player.const.ATTR_MEDIA_VOLUME_LEVEL: volume,
}
@ -632,7 +648,7 @@ async def async_api_adjust_volume_step(
if is_default:
volume_int = default_steps
data = {ATTR_ENTITY_ID: entity.entity_id}
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
for _ in range(abs(volume_int)):
await hass.services.async_call(
@ -653,7 +669,7 @@ async def async_api_set_mute(
"""Process a set mute request."""
mute = bool(directive.payload["mute"])
entity = directive.entity
data = {
data: dict[str, Any] = {
ATTR_ENTITY_ID: entity.entity_id,
media_player.const.ATTR_MEDIA_VOLUME_MUTED: mute,
}
@ -674,7 +690,7 @@ async def async_api_play(
) -> AlexaResponse:
"""Process a play request."""
entity = directive.entity
data = {ATTR_ENTITY_ID: entity.entity_id}
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
await hass.services.async_call(
entity.domain, SERVICE_MEDIA_PLAY, data, blocking=False, context=context
@ -692,7 +708,7 @@ async def async_api_pause(
) -> AlexaResponse:
"""Process a pause request."""
entity = directive.entity
data = {ATTR_ENTITY_ID: entity.entity_id}
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
await hass.services.async_call(
entity.domain, SERVICE_MEDIA_PAUSE, data, blocking=False, context=context
@ -710,7 +726,7 @@ async def async_api_stop(
) -> AlexaResponse:
"""Process a stop request."""
entity = directive.entity
data = {ATTR_ENTITY_ID: entity.entity_id}
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
await hass.services.async_call(
entity.domain, SERVICE_MEDIA_STOP, data, blocking=False, context=context
@ -728,7 +744,7 @@ async def async_api_next(
) -> AlexaResponse:
"""Process a next request."""
entity = directive.entity
data = {ATTR_ENTITY_ID: entity.entity_id}
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
await hass.services.async_call(
entity.domain, SERVICE_MEDIA_NEXT_TRACK, data, blocking=False, context=context
@ -746,7 +762,7 @@ async def async_api_previous(
) -> AlexaResponse:
"""Process a previous request."""
entity = directive.entity
data = {ATTR_ENTITY_ID: entity.entity_id}
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
await hass.services.async_call(
entity.domain,
@ -759,7 +775,9 @@ async def async_api_previous(
return directive.response()
def temperature_from_object(hass, temp_obj, interval=False):
def temperature_from_object(
hass: ha.HomeAssistant, temp_obj: dict[str, Any], interval: bool = False
) -> float:
"""Get temperature from Temperature object in requested unit."""
to_unit = hass.config.units.temperature_unit
from_unit = UnitOfTemperature.CELSIUS
@ -785,11 +803,11 @@ async def async_api_set_target_temp(
) -> AlexaResponse:
"""Process a set target temperature request."""
entity = directive.entity
min_temp = entity.attributes.get(climate.ATTR_MIN_TEMP)
max_temp = entity.attributes.get(climate.ATTR_MAX_TEMP)
min_temp = entity.attributes[climate.ATTR_MIN_TEMP]
max_temp = entity.attributes[climate.ATTR_MAX_TEMP]
unit = hass.config.units.temperature_unit
data = {ATTR_ENTITY_ID: entity.entity_id}
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
payload = directive.payload
response = directive.response()
@ -849,9 +867,10 @@ async def async_api_adjust_target_temp(
context: ha.Context,
) -> AlexaResponse:
"""Process an adjust target temperature request."""
data: dict[str, Any]
entity = directive.entity
min_temp = entity.attributes.get(climate.ATTR_MIN_TEMP)
max_temp = entity.attributes.get(climate.ATTR_MAX_TEMP)
min_temp = entity.attributes[climate.ATTR_MIN_TEMP]
max_temp = entity.attributes[climate.ATTR_MAX_TEMP]
unit = hass.config.units.temperature_unit
temp_delta = temperature_from_object(
@ -862,7 +881,7 @@ async def async_api_adjust_target_temp(
current_target_temp_high = entity.attributes.get(climate.ATTR_TARGET_TEMP_HIGH)
current_target_temp_low = entity.attributes.get(climate.ATTR_TARGET_TEMP_LOW)
if current_target_temp_high and current_target_temp_low:
if current_target_temp_high is not None and current_target_temp_low is not None:
target_temp_high = float(current_target_temp_high) + temp_delta
if target_temp_high < min_temp or target_temp_high > max_temp:
raise AlexaTempRangeError(hass, target_temp_high, min_temp, max_temp)
@ -892,7 +911,7 @@ async def async_api_adjust_target_temp(
}
)
else:
target_temp = float(entity.attributes.get(ATTR_TEMPERATURE)) + temp_delta
target_temp = float(entity.attributes[ATTR_TEMPERATURE]) + temp_delta
if target_temp < min_temp or target_temp > max_temp:
raise AlexaTempRangeError(hass, target_temp, min_temp, max_temp)
@ -925,11 +944,13 @@ async def async_api_set_thermostat_mode(
context: ha.Context,
) -> AlexaResponse:
"""Process a set thermostat mode request."""
operation_list: list[str]
entity = directive.entity
mode = directive.payload["thermostatMode"]
mode = mode if isinstance(mode, str) else mode["value"]
data = {ATTR_ENTITY_ID: entity.entity_id}
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
ha_preset = next((k for k, v in API_THERMOSTAT_PRESETS.items() if v == mode), None)
@ -944,7 +965,7 @@ async def async_api_set_thermostat_mode(
data[climate.ATTR_PRESET_MODE] = ha_preset
elif mode == "CUSTOM":
operation_list = entity.attributes.get(climate.ATTR_HVAC_MODES)
operation_list = entity.attributes.get(climate.ATTR_HVAC_MODES, [])
custom_mode = directive.payload["thermostatMode"]["customName"]
custom_mode = next(
(k for k, v in API_THERMOSTAT_MODES_CUSTOM.items() if v == custom_mode),
@ -960,9 +981,13 @@ async def async_api_set_thermostat_mode(
data[climate.ATTR_HVAC_MODE] = custom_mode
else:
operation_list = entity.attributes.get(climate.ATTR_HVAC_MODES)
ha_modes = {k: v for k, v in API_THERMOSTAT_MODES.items() if v == mode}
ha_mode = next(iter(set(ha_modes).intersection(operation_list)), None)
operation_list = entity.attributes.get(climate.ATTR_HVAC_MODES, [])
ha_modes: dict[str, str] = {
k: v for k, v in API_THERMOSTAT_MODES.items() if v == mode
}
ha_mode: str | None = next(
iter(set(ha_modes).intersection(operation_list)), None
)
if ha_mode not in operation_list:
msg = f"The requested thermostat mode {mode} is not supported"
raise AlexaUnsupportedThermostatModeError(msg)
@ -1007,7 +1032,7 @@ async def async_api_arm(
entity = directive.entity
service = None
arm_state = directive.payload["armState"]
data = {ATTR_ENTITY_ID: entity.entity_id}
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
if entity.state != STATE_ALARM_DISARMED:
msg = "You must disarm the system before you can set the requested arm state."
@ -1027,7 +1052,7 @@ async def async_api_arm(
)
# return 0 until alarm integration supports an exit delay
payload = {"exitDelayInSeconds": 0}
payload: dict[str, Any] = {"exitDelayInSeconds": 0}
response = directive.response(
name="Arm.Response", namespace="Alexa.SecurityPanelController", payload=payload
@ -1053,7 +1078,7 @@ async def async_api_disarm(
) -> AlexaResponse:
"""Process a Security Panel Disarm request."""
entity = directive.entity
data = {ATTR_ENTITY_ID: entity.entity_id}
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
response = directive.response()
# Per Alexa Documentation: If you receive a Disarm directive, and the
@ -1095,7 +1120,7 @@ async def async_api_set_mode(
instance = directive.instance
domain = entity.domain
service = None
data = {ATTR_ENTITY_ID: entity.entity_id}
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
mode = directive.payload["mode"]
# Fan Direction
@ -1108,8 +1133,11 @@ async def async_api_set_mode(
# Fan preset_mode
elif instance == f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}":
preset_mode = mode.split(".")[1]
if preset_mode != PRESET_MODE_NA and preset_mode in entity.attributes.get(
fan.ATTR_PRESET_MODES
preset_modes: list[str] | None = entity.attributes.get(fan.ATTR_PRESET_MODES)
if (
preset_mode != PRESET_MODE_NA
and preset_modes
and preset_mode in preset_modes
):
service = fan.SERVICE_SET_PRESET_MODE
data[fan.ATTR_PRESET_MODE] = preset_mode
@ -1120,9 +1148,8 @@ async def async_api_set_mode(
# Humidifier mode
elif instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_MODE}":
mode = mode.split(".")[1]
if mode != PRESET_MODE_NA and mode in entity.attributes.get(
humidifier.ATTR_AVAILABLE_MODES
):
modes: list[str] | None = entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES)
if mode != PRESET_MODE_NA and modes and mode in modes:
service = humidifier.SERVICE_SET_MODE
data[humidifier.ATTR_MODE] = mode
else:
@ -1195,7 +1222,7 @@ async def async_api_toggle_on(
raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED)
service = fan.SERVICE_OSCILLATE
data = {
data: dict[str, Any] = {
ATTR_ENTITY_ID: entity.entity_id,
fan.ATTR_OSCILLATING: True,
}
@ -1234,7 +1261,7 @@ async def async_api_toggle_off(
raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED)
service = fan.SERVICE_OSCILLATE
data = {
data: dict[str, Any] = {
ATTR_ENTITY_ID: entity.entity_id,
fan.ATTR_OSCILLATING: False,
}
@ -1268,7 +1295,7 @@ async def async_api_set_range(
instance = directive.instance
domain = entity.domain
service = None
data = {ATTR_ENTITY_ID: entity.entity_id}
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
range_value = directive.payload["rangeValue"]
# Cover Position
@ -1537,7 +1564,7 @@ async def async_api_changechannel(
channel = metadata_payload["name"]
payload_name = "callSign"
data = {
data: dict[str, Any] = {
ATTR_ENTITY_ID: entity.entity_id,
media_player.const.ATTR_MEDIA_CONTENT_ID: channel,
media_player.const.ATTR_MEDIA_CONTENT_TYPE: (
@ -1577,7 +1604,7 @@ async def async_api_skipchannel(
channel = int(directive.payload["channelCount"])
entity = directive.entity
data = {ATTR_ENTITY_ID: entity.entity_id}
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
if channel < 0:
service_media = SERVICE_MEDIA_PREVIOUS_TRACK
@ -1624,7 +1651,7 @@ async def async_api_seek(
if media_duration and 0 < int(media_duration) < seek_position:
seek_position = media_duration
data = {
data: dict[str, Any] = {
ATTR_ENTITY_ID: entity.entity_id,
media_player.ATTR_MEDIA_SEEK_POSITION: seek_position,
}
@ -1640,7 +1667,9 @@ async def async_api_seek(
# convert seconds to milliseconds for StateReport.
seek_position = int(seek_position * 1000)
payload = {"properties": [{"name": "positionMilliseconds", "value": seek_position}]}
payload: dict[str, Any] = {
"properties": [{"name": "positionMilliseconds", "value": seek_position}]
}
return directive.response(
name="StateReport", namespace="Alexa.SeekController", payload=payload
)
@ -1656,7 +1685,7 @@ async def async_api_set_eq_mode(
"""Process a SetMode request for EqualizerController."""
mode = directive.payload["mode"]
entity = directive.entity
data = {ATTR_ENTITY_ID: entity.entity_id}
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
sound_mode_list = entity.attributes.get(media_player.const.ATTR_SOUND_MODE_LIST)
if sound_mode_list and mode.lower() in sound_mode_list:
@ -1702,7 +1731,7 @@ async def async_api_hold(
) -> AlexaResponse:
"""Process a TimeHoldController Hold request."""
entity = directive.entity
data = {ATTR_ENTITY_ID: entity.entity_id}
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
if entity.domain == timer.DOMAIN:
service = timer.SERVICE_PAUSE
@ -1729,7 +1758,7 @@ async def async_api_resume(
) -> AlexaResponse:
"""Process a TimeHoldController Resume request."""
entity = directive.entity
data = {ATTR_ENTITY_ID: entity.entity_id}
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
if entity.domain == timer.DOMAIN:
service = timer.SERVICE_START
@ -1774,7 +1803,7 @@ async def async_api_initialize_camera_stream(
"Failed to find suitable URL to serve to Alexa"
) from err
payload = {
payload: dict[str, Any] = {
"cameraStreams": [
{
"uri": f"{external_url}{stream_source}",

View File

@ -3,8 +3,10 @@ import enum
import logging
from typing import Any
from aiohttp.web import Response
from homeassistant.components import http
from homeassistant.core import callback
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import intent
from homeassistant.util.decorator import Registry
@ -18,7 +20,7 @@ HANDLERS = Registry() # type: ignore[var-annotated]
INTENTS_API_ENDPOINT = "/api/alexa"
class SpeechType(enum.Enum):
class SpeechType(enum.StrEnum):
"""The Alexa speech types."""
plaintext = "PlainText"
@ -28,7 +30,7 @@ class SpeechType(enum.Enum):
SPEECH_MAPPINGS = {"plain": SpeechType.plaintext, "ssml": SpeechType.ssml}
class CardType(enum.Enum):
class CardType(enum.StrEnum):
"""The Alexa card types."""
simple = "Simple"
@ -36,12 +38,12 @@ class CardType(enum.Enum):
@callback
def async_setup(hass):
def async_setup(hass: HomeAssistant) -> None:
"""Activate Alexa component."""
hass.http.register_view(AlexaIntentsView)
async def async_setup_intents(hass):
async def async_setup_intents(hass: HomeAssistant) -> None:
"""Do intents setup.
Right now this module does not expose any, but the intent component breaks
@ -60,15 +62,15 @@ class AlexaIntentsView(http.HomeAssistantView):
url = INTENTS_API_ENDPOINT
name = "api:alexa"
async def post(self, request):
async def post(self, request: http.HomeAssistantRequest) -> Response | bytes:
"""Handle Alexa."""
hass = request.app["hass"]
message = await request.json()
hass: HomeAssistant = request.app["hass"]
message: dict[str, Any] = await request.json()
_LOGGER.debug("Received Alexa request: %s", message)
try:
response = await async_handle_message(hass, message)
response: dict[str, Any] = await async_handle_message(hass, message)
return b"" if response is None else self.json(response)
except UnknownRequest as err:
_LOGGER.warning(str(err))
@ -99,15 +101,19 @@ class AlexaIntentsView(http.HomeAssistantView):
)
def intent_error_response(hass, message, error):
def intent_error_response(
hass: HomeAssistant, message: dict[str, Any], error: str
) -> dict[str, Any]:
"""Return an Alexa response that will speak the error message."""
alexa_intent_info = message.get("request").get("intent")
alexa_response = AlexaResponse(hass, alexa_intent_info)
alexa_intent_info = message["request"].get("intent")
alexa_response = AlexaIntentResponse(hass, alexa_intent_info)
alexa_response.add_speech(SpeechType.plaintext, error)
return alexa_response.as_dict()
async def async_handle_message(hass, message):
async def async_handle_message(
hass: HomeAssistant, message: dict[str, Any]
) -> dict[str, Any]:
"""Handle an Alexa intent.
Raises:
@ -117,19 +123,22 @@ async def async_handle_message(hass, message):
- intent.IntentError
"""
req = message.get("request")
req = message["request"]
req_type = req["type"]
if not (handler := HANDLERS.get(req_type)):
raise UnknownRequest(f"Received unknown request {req_type}")
return await handler(hass, message)
response: dict[str, Any] = await handler(hass, message)
return response
@HANDLERS.register("SessionEndedRequest")
@HANDLERS.register("IntentRequest")
@HANDLERS.register("LaunchRequest")
async def async_handle_intent(hass, message):
async def async_handle_intent(
hass: HomeAssistant, message: dict[str, Any]
) -> dict[str, Any]:
"""Handle an intent request.
Raises:
@ -138,9 +147,9 @@ async def async_handle_intent(hass, message):
- intent.IntentError
"""
req = message.get("request")
req = message["request"]
alexa_intent_info = req.get("intent")
alexa_response = AlexaResponse(hass, alexa_intent_info)
alexa_response = AlexaIntentResponse(hass, alexa_intent_info)
if req["type"] == "LaunchRequest":
intent_name = (
@ -187,7 +196,7 @@ def resolve_slot_data(key: str, request: dict[str, Any]) -> dict[str, str]:
# passes the id and name of the nearest possible slot resolution. For
# reference to the request object structure, see the Alexa docs:
# https://tinyurl.com/ybvm7jhs
resolved_data = {}
resolved_data: dict[str, Any] = {}
resolved_data["value"] = request["value"]
resolved_data["id"] = ""
@ -226,18 +235,18 @@ def resolve_slot_data(key: str, request: dict[str, Any]) -> dict[str, str]:
return resolved_data
class AlexaResponse:
class AlexaIntentResponse:
"""Help generating the response for Alexa."""
def __init__(self, hass, intent_info):
def __init__(self, hass: HomeAssistant, intent_info: dict[str, Any] | None) -> None:
"""Initialize the response."""
self.hass = hass
self.speech = None
self.card = None
self.reprompt = None
self.session_attributes = {}
self.speech: dict[str, Any] | None = None
self.card: dict[str, Any] | None = None
self.reprompt: dict[str, Any] | None = None
self.session_attributes: dict[str, Any] = {}
self.should_end_session = True
self.variables = {}
self.variables: dict[str, Any] = {}
# Intent is None if request was a LaunchRequest or SessionEndedRequest
if intent_info is not None:
@ -252,7 +261,7 @@ class AlexaResponse:
self.variables[_key] = _slot_data["value"]
self.variables[_key + "_Id"] = _slot_data["id"]
def add_card(self, card_type, title, content):
def add_card(self, card_type: CardType, title: str, content: str) -> None:
"""Add a card to the response."""
assert self.card is None
@ -266,7 +275,7 @@ class AlexaResponse:
card["content"] = content
self.card = card
def add_speech(self, speech_type, text):
def add_speech(self, speech_type: SpeechType, text: str) -> None:
"""Add speech to the response."""
assert self.speech is None
@ -274,7 +283,7 @@ class AlexaResponse:
self.speech = {"type": speech_type.value, key: text}
def add_reprompt(self, speech_type, text):
def add_reprompt(self, speech_type: SpeechType, text: str) -> None:
"""Add reprompt if user does not answer."""
assert self.reprompt is None
@ -284,9 +293,9 @@ class AlexaResponse:
self.reprompt = {"type": speech_type.value, key: text}
def as_dict(self):
def as_dict(self) -> dict[str, Any]:
"""Return response in an Alexa valid dict."""
response = {"shouldEndSession": self.should_end_session}
response: dict[str, Any] = {"shouldEndSession": self.should_end_session}
if self.card is not None:
response["card"] = self.card

View File

@ -1,20 +1,26 @@
"""Describe logbook events."""
from collections.abc import Callable
from typing import Any
from homeassistant.components.logbook import (
LOGBOOK_ENTRY_ENTITY_ID,
LOGBOOK_ENTRY_MESSAGE,
LOGBOOK_ENTRY_NAME,
)
from homeassistant.core import callback
from homeassistant.core import Event, HomeAssistant, callback
from .const import DOMAIN, EVENT_ALEXA_SMART_HOME
@callback
def async_describe_events(hass, async_describe_event):
def async_describe_events(
hass: HomeAssistant,
async_describe_event: Callable[[str, str, Callable[[Event], dict[str, str]]], None],
) -> None:
"""Describe logbook events."""
@callback
def async_describe_logbook_event(event):
def async_describe_logbook_event(event: Event) -> dict[str, Any]:
"""Describe a logbook event."""
data = event.data

View File

@ -1,195 +0,0 @@
"""Alexa models."""
import logging
from uuid import uuid4
from .const import (
API_CONTEXT,
API_DIRECTIVE,
API_ENDPOINT,
API_EVENT,
API_HEADER,
API_PAYLOAD,
API_SCOPE,
)
from .entities import ENTITY_ADAPTERS
from .errors import AlexaInvalidEndpointError
_LOGGER = logging.getLogger(__name__)
class AlexaDirective:
"""An incoming Alexa directive."""
def __init__(self, request):
"""Initialize a directive."""
self._directive = request[API_DIRECTIVE]
self.namespace = self._directive[API_HEADER]["namespace"]
self.name = self._directive[API_HEADER]["name"]
self.payload = self._directive[API_PAYLOAD]
self.has_endpoint = API_ENDPOINT in self._directive
self.entity = self.entity_id = self.endpoint = self.instance = None
def load_entity(self, hass, config):
"""Set attributes related to the entity for this request.
Sets these attributes when self.has_endpoint is True:
- entity
- entity_id
- endpoint
- instance (when header includes instance property)
Behavior when self.has_endpoint is False is undefined.
Will raise AlexaInvalidEndpointError if the endpoint in the request is
malformed or nonexistent.
"""
_endpoint_id = self._directive[API_ENDPOINT]["endpointId"]
self.entity_id = _endpoint_id.replace("#", ".")
self.entity = hass.states.get(self.entity_id)
if not self.entity or not config.should_expose(self.entity_id):
raise AlexaInvalidEndpointError(_endpoint_id)
self.endpoint = ENTITY_ADAPTERS[self.entity.domain](hass, config, self.entity)
if "instance" in self._directive[API_HEADER]:
self.instance = self._directive[API_HEADER]["instance"]
def response(self, name="Response", namespace="Alexa", payload=None):
"""Create an API formatted response.
Async friendly.
"""
response = AlexaResponse(name, namespace, payload)
token = self._directive[API_HEADER].get("correlationToken")
if token:
response.set_correlation_token(token)
if self.has_endpoint:
response.set_endpoint(self._directive[API_ENDPOINT].copy())
return response
def error(
self,
namespace="Alexa",
error_type="INTERNAL_ERROR",
error_message="",
payload=None,
):
"""Create a API formatted error response.
Async friendly.
"""
payload = payload or {}
payload["type"] = error_type
payload["message"] = error_message
_LOGGER.info(
"Request %s/%s error %s: %s",
self._directive[API_HEADER]["namespace"],
self._directive[API_HEADER]["name"],
error_type,
error_message,
)
return self.response(name="ErrorResponse", namespace=namespace, payload=payload)
class AlexaResponse:
"""Class to hold a response."""
def __init__(self, name, namespace, payload=None):
"""Initialize the response."""
payload = payload or {}
self._response = {
API_EVENT: {
API_HEADER: {
"namespace": namespace,
"name": name,
"messageId": str(uuid4()),
"payloadVersion": "3",
},
API_PAYLOAD: payload,
}
}
@property
def name(self):
"""Return the name of this response."""
return self._response[API_EVENT][API_HEADER]["name"]
@property
def namespace(self):
"""Return the namespace of this response."""
return self._response[API_EVENT][API_HEADER]["namespace"]
def set_correlation_token(self, token):
"""Set the correlationToken.
This should normally mirror the value from a request, and is set by
AlexaDirective.response() usually.
"""
self._response[API_EVENT][API_HEADER]["correlationToken"] = token
def set_endpoint_full(self, bearer_token, endpoint_id, cookie=None):
"""Set the endpoint dictionary.
This is used to send proactive messages to Alexa.
"""
self._response[API_EVENT][API_ENDPOINT] = {
API_SCOPE: {"type": "BearerToken", "token": bearer_token}
}
if endpoint_id is not None:
self._response[API_EVENT][API_ENDPOINT]["endpointId"] = endpoint_id
if cookie is not None:
self._response[API_EVENT][API_ENDPOINT]["cookie"] = cookie
def set_endpoint(self, endpoint):
"""Set the endpoint.
This should normally mirror the value from a request, and is set by
AlexaDirective.response() usually.
"""
self._response[API_EVENT][API_ENDPOINT] = endpoint
def _properties(self):
context = self._response.setdefault(API_CONTEXT, {})
return context.setdefault("properties", [])
def add_context_property(self, prop):
"""Add a property to the response context.
The Alexa response includes a list of properties which provides
feedback on how states have changed. For example if a user asks,
"Alexa, set thermostat to 20 degrees", the API expects a response with
the new value of the property, and Alexa will respond to the user
"Thermostat set to 20 degrees".
async_handle_message() will call .merge_context_properties() for every
request automatically, however often handlers will call services to
change state but the effects of those changes are applied
asynchronously. Thus, handlers should call this method to confirm
changes before returning.
"""
self._properties().append(prop)
def merge_context_properties(self, endpoint):
"""Add all properties from given endpoint if not already set.
Handlers should be using .add_context_property().
"""
properties = self._properties()
already_set = {(p["namespace"], p["name"]) for p in properties}
for prop in endpoint.serialize_properties():
if (prop["namespace"], prop["name"]) not in already_set:
self.add_context_property(prop)
def serialize(self):
"""Return response as a JSON-able data structure."""
return self._response

View File

@ -1,6 +1,9 @@
"""Alexa Resources and Assets."""
from typing import Any
class AlexaGlobalCatalog:
"""The Global Alexa catalog.
@ -207,36 +210,40 @@ class AlexaCapabilityResource:
https://developer.amazon.com/docs/device-apis/resources-and-assets.html#capability-resources
"""
def __init__(self, labels):
def __init__(self, labels: list[str]) -> None:
"""Initialize an Alexa resource."""
self._resource_labels = []
for label in labels:
self._resource_labels.append(label)
def serialize_capability_resources(self):
def serialize_capability_resources(self) -> dict[str, list[dict[str, Any]]]:
"""Return capabilityResources object serialized for an API response."""
return self.serialize_labels(self._resource_labels)
def serialize_configuration(self):
def serialize_configuration(self) -> dict[str, Any]:
"""Return serialized configuration for an API response.
Return ModeResources, PresetResources friendlyNames serialized.
"""
return []
raise NotImplementedError()
def serialize_labels(self, resources):
def serialize_labels(self, resources: list[str]) -> dict[str, list[dict[str, Any]]]:
"""Return serialized labels for an API response.
Returns resource label objects for friendlyNames serialized.
"""
labels = []
labels: list[dict[str, Any]] = []
label_dict: dict[str, Any]
for label in resources:
if label in AlexaGlobalCatalog.__dict__.values():
label = {"@type": "asset", "value": {"assetId": label}}
label_dict = {"@type": "asset", "value": {"assetId": label}}
else:
label = {"@type": "text", "value": {"text": label, "locale": "en-US"}}
label_dict = {
"@type": "text",
"value": {"text": label, "locale": "en-US"},
}
labels.append(label)
labels.append(label_dict)
return {"friendlyNames": labels}
@ -247,22 +254,22 @@ class AlexaModeResource(AlexaCapabilityResource):
https://developer.amazon.com/docs/device-apis/resources-and-assets.html#capability-resources
"""
def __init__(self, labels, ordered=False):
def __init__(self, labels: list[str], ordered: bool = False) -> None:
"""Initialize an Alexa modeResource."""
super().__init__(labels)
self._supported_modes = []
self._mode_ordered = ordered
self._supported_modes: list[dict[str, Any]] = []
self._mode_ordered: bool = ordered
def add_mode(self, value, labels):
def add_mode(self, value: str, labels: list[str]) -> None:
"""Add mode to the supportedModes object."""
self._supported_modes.append({"value": value, "labels": labels})
def serialize_configuration(self):
def serialize_configuration(self) -> dict[str, Any]:
"""Return serialized configuration for an API response.
Returns configuration for ModeResources friendlyNames serialized.
"""
mode_resources = []
mode_resources: list[dict[str, Any]] = []
for mode in self._supported_modes:
result = {
"value": mode["value"],
@ -282,10 +289,17 @@ class AlexaPresetResource(AlexaCapabilityResource):
https://developer.amazon.com/docs/device-apis/resources-and-assets.html#presetresources
"""
def __init__(self, labels, min_value, max_value, precision, unit=None):
def __init__(
self,
labels: list[str],
min_value: int | float,
max_value: int | float,
precision: int | float,
unit: str | None = None,
) -> None:
"""Initialize an Alexa presetResource."""
super().__init__(labels)
self._presets = []
self._presets: list[dict[str, Any]] = []
self._minimum_value = min_value
self._maximum_value = max_value
self._precision = precision
@ -293,16 +307,16 @@ class AlexaPresetResource(AlexaCapabilityResource):
if unit in AlexaGlobalCatalog.__dict__.values():
self._unit_of_measure = unit
def add_preset(self, value, labels):
def add_preset(self, value: int | float, labels: list[str]) -> None:
"""Add preset to configuration presets array."""
self._presets.append({"value": value, "labels": labels})
def serialize_configuration(self):
def serialize_configuration(self) -> dict[str, Any]:
"""Return serialized configuration for an API response.
Returns configuration for PresetResources friendlyNames serialized.
"""
configuration = {
configuration: dict[str, Any] = {
"supportedRange": {
"minimumValue": self._minimum_value,
"maximumValue": self._maximum_value,
@ -372,26 +386,28 @@ class AlexaSemantics:
DIRECTIVE_MODE_SET_MODE = "SetMode"
DIRECTIVE_MODE_ADJUST_MODE = "AdjustMode"
def __init__(self):
def __init__(self) -> None:
"""Initialize an Alexa modeResource."""
self._action_mappings = []
self._state_mappings = []
self._action_mappings: list[dict[str, Any]] = []
self._state_mappings: list[dict[str, Any]] = []
def _add_action_mapping(self, semantics):
def _add_action_mapping(self, semantics: dict[str, Any]) -> None:
"""Add action mapping between actions and interface directives."""
self._action_mappings.append(semantics)
def _add_state_mapping(self, semantics):
def _add_state_mapping(self, semantics: dict[str, Any]) -> None:
"""Add state mapping between states and interface directives."""
self._state_mappings.append(semantics)
def add_states_to_value(self, states, value):
def add_states_to_value(self, states: list[str], value: Any) -> None:
"""Add StatesToValue stateMappings."""
self._add_state_mapping(
{"@type": self.STATES_TO_VALUE, "states": states, "value": value}
)
def add_states_to_range(self, states, min_value, max_value):
def add_states_to_range(
self, states: list[str], min_value: int | float, max_value: int | float
) -> None:
"""Add StatesToRange stateMappings."""
self._add_state_mapping(
{
@ -401,7 +417,9 @@ class AlexaSemantics:
}
)
def add_action_to_directive(self, actions, directive, payload):
def add_action_to_directive(
self, actions: list[str], directive: str, payload: dict[str, Any]
) -> None:
"""Add ActionsToDirective actionMappings."""
self._add_action_mapping(
{
@ -411,9 +429,9 @@ class AlexaSemantics:
}
)
def serialize_semantics(self):
def serialize_semantics(self) -> dict[str, Any]:
"""Return semantics object serialized for an API response."""
semantics = {}
semantics: dict[str, Any] = {}
if self._action_mappings:
semantics[self.MAPPINGS_ACTION] = self._action_mappings
if self._state_mappings:

View File

@ -1,17 +1,170 @@
"""Support for alexa Smart Home Skill API."""
import logging
from typing import Any
import homeassistant.core as ha
from aiohttp import web
from yarl import URL
from .const import API_DIRECTIVE, API_HEADER, EVENT_ALEXA_SMART_HOME
from homeassistant import core
from homeassistant.auth.models import User
from homeassistant.components.http import HomeAssistantRequest
from homeassistant.components.http.view import HomeAssistantView
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
from homeassistant.core import Context, HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.typing import ConfigType
from .auth import Auth
from .config import AbstractConfig
from .const import (
API_DIRECTIVE,
API_HEADER,
CONF_ENDPOINT,
CONF_ENTITY_CONFIG,
CONF_FILTER,
CONF_LOCALE,
EVENT_ALEXA_SMART_HOME,
)
from .errors import AlexaBridgeUnreachableError, AlexaError
from .handlers import HANDLERS
from .messages import AlexaDirective
from .state_report import AlexaDirective
_LOGGER = logging.getLogger(__name__)
SMART_HOME_HTTP_ENDPOINT = "/api/alexa/smart_home"
async def async_handle_message(hass, config, request, context=None, enabled=True):
class AlexaConfig(AbstractConfig):
"""Alexa config."""
_auth: Auth | None
def __init__(self, hass: HomeAssistant, config: ConfigType) -> None:
"""Initialize Alexa config."""
super().__init__(hass)
self._config = config
if config.get(CONF_CLIENT_ID) and config.get(CONF_CLIENT_SECRET):
self._auth = Auth(hass, config[CONF_CLIENT_ID], config[CONF_CLIENT_SECRET])
else:
self._auth = None
@property
def supports_auth(self) -> bool:
"""Return if config supports auth."""
return self._auth is not None
@property
def should_report_state(self) -> bool:
"""Return if we should proactively report states."""
return self._auth is not None and self.authorized
@property
def endpoint(self) -> str | URL | None:
"""Endpoint for report state."""
return self._config.get(CONF_ENDPOINT)
@property
def entity_config(self) -> dict[str, Any]:
"""Return entity config."""
return self._config.get(CONF_ENTITY_CONFIG) or {}
@property
def locale(self) -> str | None:
"""Return config locale."""
return self._config.get(CONF_LOCALE)
@core.callback
def user_identifier(self) -> str:
"""Return an identifier for the user that represents this config."""
return ""
@core.callback
def should_expose(self, entity_id: str) -> bool:
"""If an entity should be exposed."""
if not self._config[CONF_FILTER].empty_filter:
return bool(self._config[CONF_FILTER](entity_id))
entity_registry = er.async_get(self.hass)
if registry_entry := entity_registry.async_get(entity_id):
auxiliary_entity = (
registry_entry.entity_category is not None
or registry_entry.hidden_by is not None
)
else:
auxiliary_entity = False
return not auxiliary_entity
@core.callback
def async_invalidate_access_token(self) -> None:
"""Invalidate access token."""
assert self._auth is not None
self._auth.async_invalidate_access_token()
async def async_get_access_token(self) -> str | None:
"""Get an access token."""
assert self._auth is not None
return await self._auth.async_get_access_token()
async def async_accept_grant(self, code: str) -> str | None:
"""Accept a grant."""
assert self._auth is not None
return await self._auth.async_do_auth(code)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> None:
"""Activate Smart Home functionality of Alexa component.
This is optional, triggered by having a `smart_home:` sub-section in the
alexa configuration.
Even if that's disabled, the functionality in this module may still be used
by the cloud component which will call async_handle_message directly.
"""
smart_home_config = AlexaConfig(hass, config)
await smart_home_config.async_initialize()
hass.http.register_view(SmartHomeView(smart_home_config))
if smart_home_config.should_report_state:
await smart_home_config.async_enable_proactive_mode()
class SmartHomeView(HomeAssistantView):
"""Expose Smart Home v3 payload interface via HTTP POST."""
url = SMART_HOME_HTTP_ENDPOINT
name = "api:alexa:smart_home"
def __init__(self, smart_home_config: AlexaConfig) -> None:
"""Initialize."""
self.smart_home_config = smart_home_config
async def post(self, request: HomeAssistantRequest) -> web.Response | bytes:
"""Handle Alexa Smart Home requests.
The Smart Home API requires the endpoint to be implemented in AWS
Lambda, which will need to forward the requests to here and pass back
the response.
"""
hass: HomeAssistant = request.app["hass"]
user: User = request["hass_user"]
message: dict[str, Any] = await request.json()
_LOGGER.debug("Received Alexa Smart Home request: %s", message)
response = await async_handle_message(
hass, self.smart_home_config, message, context=core.Context(user_id=user.id)
)
_LOGGER.debug("Sending Alexa Smart Home response: %s", response)
return b"" if response is None else self.json(response)
async def async_handle_message(
hass: HomeAssistant,
config: AbstractConfig,
request: dict[str, Any],
context: Context | None = None,
enabled: bool = True,
) -> dict[str, Any]:
"""Handle incoming API messages.
If enabled is False, the response to all messages will be a
@ -21,7 +174,7 @@ async def async_handle_message(hass, config, request, context=None, enabled=True
assert request[API_DIRECTIVE][API_HEADER]["payloadVersion"] == "3"
if context is None:
context = ha.Context()
context = Context()
directive = AlexaDirective(request)
@ -48,7 +201,7 @@ async def async_handle_message(hass, config, request, context=None, enabled=True
response = directive.error()
except AlexaError as err:
response = directive.error(
error_type=err.error_type,
error_type=str(err.error_type),
error_message=err.error_message,
payload=err.payload,
)
@ -61,9 +214,13 @@ async def async_handle_message(hass, config, request, context=None, enabled=True
)
response = directive.error(error_message="Unknown error")
request_info = {"namespace": directive.namespace, "name": directive.name}
request_info: dict[str, Any] = {
"namespace": directive.namespace,
"name": directive.name,
}
if directive.has_endpoint:
assert directive.entity_id is not None
request_info["entity_id"] = directive.entity_id
hass.bus.async_fire(

View File

@ -1,137 +0,0 @@
"""Alexa HTTP interface."""
import logging
from homeassistant import core
from homeassistant.components.http.view import HomeAssistantView
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.typing import ConfigType
from .auth import Auth
from .config import AbstractConfig
from .const import CONF_ENDPOINT, CONF_ENTITY_CONFIG, CONF_FILTER, CONF_LOCALE
from .smart_home import async_handle_message
_LOGGER = logging.getLogger(__name__)
SMART_HOME_HTTP_ENDPOINT = "/api/alexa/smart_home"
class AlexaConfig(AbstractConfig):
"""Alexa config."""
def __init__(self, hass, config):
"""Initialize Alexa config."""
super().__init__(hass)
self._config = config
if config.get(CONF_CLIENT_ID) and config.get(CONF_CLIENT_SECRET):
self._auth = Auth(hass, config[CONF_CLIENT_ID], config[CONF_CLIENT_SECRET])
else:
self._auth = None
@property
def supports_auth(self):
"""Return if config supports auth."""
return self._auth is not None
@property
def should_report_state(self):
"""Return if we should proactively report states."""
return self._auth is not None and self.authorized
@property
def endpoint(self):
"""Endpoint for report state."""
return self._config.get(CONF_ENDPOINT)
@property
def entity_config(self):
"""Return entity config."""
return self._config.get(CONF_ENTITY_CONFIG) or {}
@property
def locale(self):
"""Return config locale."""
return self._config.get(CONF_LOCALE)
@core.callback
def user_identifier(self):
"""Return an identifier for the user that represents this config."""
return ""
@core.callback
def should_expose(self, entity_id):
"""If an entity should be exposed."""
if not self._config[CONF_FILTER].empty_filter:
return self._config[CONF_FILTER](entity_id)
entity_registry = er.async_get(self.hass)
if registry_entry := entity_registry.async_get(entity_id):
auxiliary_entity = (
registry_entry.entity_category is not None
or registry_entry.hidden_by is not None
)
else:
auxiliary_entity = False
return not auxiliary_entity
@core.callback
def async_invalidate_access_token(self):
"""Invalidate access token."""
self._auth.async_invalidate_access_token()
async def async_get_access_token(self):
"""Get an access token."""
return await self._auth.async_get_access_token()
async def async_accept_grant(self, code):
"""Accept a grant."""
return await self._auth.async_do_auth(code)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> None:
"""Activate Smart Home functionality of Alexa component.
This is optional, triggered by having a `smart_home:` sub-section in the
alexa configuration.
Even if that's disabled, the functionality in this module may still be used
by the cloud component which will call async_handle_message directly.
"""
smart_home_config = AlexaConfig(hass, config)
await smart_home_config.async_initialize()
hass.http.register_view(SmartHomeView(smart_home_config))
if smart_home_config.should_report_state:
await smart_home_config.async_enable_proactive_mode()
class SmartHomeView(HomeAssistantView):
"""Expose Smart Home v3 payload interface via HTTP POST."""
url = SMART_HOME_HTTP_ENDPOINT
name = "api:alexa:smart_home"
def __init__(self, smart_home_config):
"""Initialize."""
self.smart_home_config = smart_home_config
async def post(self, request):
"""Handle Alexa Smart Home requests.
The Smart Home API requires the endpoint to be implemented in AWS
Lambda, which will need to forward the requests to here and pass back
the response.
"""
hass = request.app["hass"]
user = request["hass_user"]
message = await request.json()
_LOGGER.debug("Received Alexa Smart Home request: %s", message)
response = await async_handle_message(
hass, self.smart_home_config, message, context=core.Context(user_id=user.id)
)
_LOGGER.debug("Sending Alexa Smart Home response: %s", response)
return b"" if response is None else self.json(response)

View File

@ -2,27 +2,40 @@
from __future__ import annotations
import asyncio
from asyncio import timeout
from http import HTTPStatus
import json
import logging
from typing import TYPE_CHECKING, cast
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, cast
from uuid import uuid4
import aiohttp
import async_timeout
from homeassistant.components import event
from homeassistant.const import MATCH_ALL, STATE_ON
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import async_track_state_change
from homeassistant.helpers.significant_change import create_checker
import homeassistant.util.dt as dt_util
from homeassistant.util.json import JsonObjectType, json_loads_object
from .const import API_CHANGE, DATE_FORMAT, DOMAIN, Cause
from .const import (
API_CHANGE,
API_CONTEXT,
API_DIRECTIVE,
API_ENDPOINT,
API_EVENT,
API_HEADER,
API_PAYLOAD,
API_SCOPE,
DATE_FORMAT,
DOMAIN,
Cause,
)
from .entities import ENTITY_ADAPTERS, AlexaEntity, generate_alexa_id
from .errors import NoTokenAvailable, RequireRelink
from .messages import AlexaResponse
from .errors import AlexaInvalidEndpointError, NoTokenAvailable, RequireRelink
if TYPE_CHECKING:
from .config import AbstractConfig
@ -31,7 +44,202 @@ _LOGGER = logging.getLogger(__name__)
DEFAULT_TIMEOUT = 10
async def async_enable_proactive_mode(hass, smart_home_config):
class AlexaDirective:
"""An incoming Alexa directive."""
entity: State
entity_id: str | None
endpoint: AlexaEntity
instance: str | None
def __init__(self, request: dict[str, Any]) -> None:
"""Initialize a directive."""
self._directive: dict[str, Any] = request[API_DIRECTIVE]
self.namespace: str = self._directive[API_HEADER]["namespace"]
self.name: str = self._directive[API_HEADER]["name"]
self.payload: dict[str, Any] = self._directive[API_PAYLOAD]
self.has_endpoint: bool = API_ENDPOINT in self._directive
self.instance = None
self.entity_id = None
def load_entity(self, hass: HomeAssistant, config: AbstractConfig) -> None:
"""Set attributes related to the entity for this request.
Sets these attributes when self.has_endpoint is True:
- entity
- entity_id
- endpoint
- instance (when header includes instance property)
Behavior when self.has_endpoint is False is undefined.
Will raise AlexaInvalidEndpointError if the endpoint in the request is
malformed or nonexistent.
"""
_endpoint_id: str = self._directive[API_ENDPOINT]["endpointId"]
self.entity_id = _endpoint_id.replace("#", ".")
entity: State | None = hass.states.get(self.entity_id)
if not entity or not config.should_expose(self.entity_id):
raise AlexaInvalidEndpointError(_endpoint_id)
self.entity = entity
self.endpoint = ENTITY_ADAPTERS[self.entity.domain](hass, config, self.entity)
if "instance" in self._directive[API_HEADER]:
self.instance = self._directive[API_HEADER]["instance"]
def response(
self,
name: str = "Response",
namespace: str = "Alexa",
payload: dict[str, Any] | None = None,
) -> AlexaResponse:
"""Create an API formatted response.
Async friendly.
"""
response = AlexaResponse(name, namespace, payload)
token = self._directive[API_HEADER].get("correlationToken")
if token:
response.set_correlation_token(token)
if self.has_endpoint:
response.set_endpoint(self._directive[API_ENDPOINT].copy())
return response
def error(
self,
namespace: str = "Alexa",
error_type: str = "INTERNAL_ERROR",
error_message: str = "",
payload: dict[str, Any] | None = None,
) -> AlexaResponse:
"""Create a API formatted error response.
Async friendly.
"""
payload = payload or {}
payload["type"] = error_type
payload["message"] = error_message
_LOGGER.info(
"Request %s/%s error %s: %s",
self._directive[API_HEADER]["namespace"],
self._directive[API_HEADER]["name"],
error_type,
error_message,
)
return self.response(name="ErrorResponse", namespace=namespace, payload=payload)
class AlexaResponse:
"""Class to hold a response."""
def __init__(
self, name: str, namespace: str, payload: dict[str, Any] | None = None
) -> None:
"""Initialize the response."""
payload = payload or {}
self._response: dict[str, Any] = {
API_EVENT: {
API_HEADER: {
"namespace": namespace,
"name": name,
"messageId": str(uuid4()),
"payloadVersion": "3",
},
API_PAYLOAD: payload,
}
}
@property
def name(self) -> str:
"""Return the name of this response."""
name: str = self._response[API_EVENT][API_HEADER]["name"]
return name
@property
def namespace(self) -> str:
"""Return the namespace of this response."""
namespace: str = self._response[API_EVENT][API_HEADER]["namespace"]
return namespace
def set_correlation_token(self, token: str) -> None:
"""Set the correlationToken.
This should normally mirror the value from a request, and is set by
AlexaDirective.response() usually.
"""
self._response[API_EVENT][API_HEADER]["correlationToken"] = token
def set_endpoint_full(
self, bearer_token: str | None, endpoint_id: str | None
) -> None:
"""Set the endpoint dictionary.
This is used to send proactive messages to Alexa.
"""
self._response[API_EVENT][API_ENDPOINT] = {
API_SCOPE: {"type": "BearerToken", "token": bearer_token}
}
if endpoint_id is not None:
self._response[API_EVENT][API_ENDPOINT]["endpointId"] = endpoint_id
def set_endpoint(self, endpoint: dict[str, Any]) -> None:
"""Set the endpoint.
This should normally mirror the value from a request, and is set by
AlexaDirective.response() usually.
"""
self._response[API_EVENT][API_ENDPOINT] = endpoint
def _properties(self) -> list[dict[str, Any]]:
context: dict[str, Any] = self._response.setdefault(API_CONTEXT, {})
properties: list[dict[str, Any]] = context.setdefault("properties", [])
return properties
def add_context_property(self, prop: dict[str, Any]) -> None:
"""Add a property to the response context.
The Alexa response includes a list of properties which provides
feedback on how states have changed. For example if a user asks,
"Alexa, set thermostat to 20 degrees", the API expects a response with
the new value of the property, and Alexa will respond to the user
"Thermostat set to 20 degrees".
async_handle_message() will call .merge_context_properties() for every
request automatically, however often handlers will call services to
change state but the effects of those changes are applied
asynchronously. Thus, handlers should call this method to confirm
changes before returning.
"""
self._properties().append(prop)
def merge_context_properties(self, endpoint: AlexaEntity) -> None:
"""Add all properties from given endpoint if not already set.
Handlers should be using .add_context_property().
"""
properties = self._properties()
already_set = {(p["namespace"], p["name"]) for p in properties}
for prop in endpoint.serialize_properties():
if (prop["namespace"], prop["name"]) not in already_set:
self.add_context_property(prop)
def serialize(self) -> dict[str, Any]:
"""Return response as a JSON-able data structure."""
return self._response
async def async_enable_proactive_mode(
hass: HomeAssistant, smart_home_config: AbstractConfig
) -> CALLBACK_TYPE | None:
"""Enable the proactive mode.
Proactive mode makes this component report state changes to Alexa.
@ -43,12 +251,12 @@ async def async_enable_proactive_mode(hass, smart_home_config):
def extra_significant_check(
hass: HomeAssistant,
old_state: str,
old_attrs: dict,
old_extra_arg: dict,
old_attrs: dict[Any, Any] | MappingProxyType[Any, Any],
old_extra_arg: Any,
new_state: str,
new_attrs: dict,
new_extra_arg: dict,
):
new_attrs: dict[str, Any] | MappingProxyType[Any, Any],
new_extra_arg: Any,
) -> bool:
"""Check if the serialized data has changed."""
return old_extra_arg is not None and old_extra_arg != new_extra_arg
@ -58,7 +266,7 @@ async def async_enable_proactive_mode(hass, smart_home_config):
changed_entity: str,
old_state: State | None,
new_state: State | None,
):
) -> None:
if not hass.is_running:
return
@ -117,8 +325,13 @@ async def async_enable_proactive_mode(hass, smart_home_config):
async def async_send_changereport_message(
hass, config, alexa_entity, alexa_properties, *, invalidate_access_token=True
):
hass: HomeAssistant,
config: AbstractConfig,
alexa_entity: AlexaEntity,
alexa_properties: list[dict[str, Any]],
*,
invalidate_access_token: bool = True,
) -> None:
"""Send a ChangeReport message for an Alexa entity.
https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#report-state-with-changereport-events
@ -132,11 +345,11 @@ async def async_send_changereport_message(
)
return
headers = {"Authorization": f"Bearer {token}"}
headers: dict[str, Any] = {"Authorization": f"Bearer {token}"}
endpoint = alexa_entity.alexa_id()
payload = {
payload: dict[str, Any] = {
API_CHANGE: {
"cause": {"type": Cause.APP_INTERACTION},
"properties": alexa_properties,
@ -149,8 +362,9 @@ async def async_send_changereport_message(
message_serialized = message.serialize()
session = async_get_clientsession(hass)
assert config.endpoint is not None
try:
async with async_timeout.timeout(DEFAULT_TIMEOUT):
async with timeout(DEFAULT_TIMEOUT):
response = await session.post(
config.endpoint,
headers=headers,
@ -203,9 +417,9 @@ async def async_send_add_or_update_message(
"""
token = await config.async_get_access_token()
headers = {"Authorization": f"Bearer {token}"}
headers: dict[str, Any] = {"Authorization": f"Bearer {token}"}
endpoints = []
endpoints: list[dict[str, Any]] = []
for entity_id in entity_ids:
if (domain := entity_id.split(".", 1)[0]) not in ENTITY_ADAPTERS:
@ -217,7 +431,10 @@ async def async_send_add_or_update_message(
alexa_entity = ENTITY_ADAPTERS[domain](hass, config, state)
endpoints.append(alexa_entity.serialize_discovery())
payload = {"endpoints": endpoints, "scope": {"type": "BearerToken", "token": token}}
payload: dict[str, Any] = {
"endpoints": endpoints,
"scope": {"type": "BearerToken", "token": token},
}
message = AlexaResponse(
name="AddOrUpdateReport", namespace="Alexa.Discovery", payload=payload
@ -226,6 +443,7 @@ async def async_send_add_or_update_message(
message_serialized = message.serialize()
session = async_get_clientsession(hass)
assert config.endpoint is not None
return await session.post(
config.endpoint, headers=headers, json=message_serialized, allow_redirects=True
)
@ -240,9 +458,9 @@ async def async_send_delete_message(
"""
token = await config.async_get_access_token()
headers = {"Authorization": f"Bearer {token}"}
headers: dict[str, Any] = {"Authorization": f"Bearer {token}"}
endpoints = []
endpoints: list[dict[str, Any]] = []
for entity_id in entity_ids:
domain = entity_id.split(".", 1)[0]
@ -252,7 +470,10 @@ async def async_send_delete_message(
endpoints.append({"endpointId": generate_alexa_id(entity_id)})
payload = {"endpoints": endpoints, "scope": {"type": "BearerToken", "token": token}}
payload: dict[str, Any] = {
"endpoints": endpoints,
"scope": {"type": "BearerToken", "token": token},
}
message = AlexaResponse(
name="DeleteReport", namespace="Alexa.Discovery", payload=payload
@ -261,19 +482,22 @@ async def async_send_delete_message(
message_serialized = message.serialize()
session = async_get_clientsession(hass)
assert config.endpoint is not None
return await session.post(
config.endpoint, headers=headers, json=message_serialized, allow_redirects=True
)
async def async_send_doorbell_event_message(hass, config, alexa_entity):
async def async_send_doorbell_event_message(
hass: HomeAssistant, config: AbstractConfig, alexa_entity: AlexaEntity
) -> None:
"""Send a DoorbellPress event message for an Alexa entity.
https://developer.amazon.com/en-US/docs/alexa/device-apis/alexa-doorbelleventsource.html
"""
token = await config.async_get_access_token()
headers = {"Authorization": f"Bearer {token}"}
headers: dict[str, Any] = {"Authorization": f"Bearer {token}"}
endpoint = alexa_entity.alexa_id()
@ -291,8 +515,9 @@ async def async_send_doorbell_event_message(hass, config, alexa_entity):
message_serialized = message.serialize()
session = async_get_clientsession(hass)
assert config.endpoint is not None
try:
async with async_timeout.timeout(DEFAULT_TIMEOUT):
async with timeout(DEFAULT_TIMEOUT):
response = await session.post(
config.endpoint,
headers=headers,

View File

@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/amazon_polly",
"iot_class": "cloud_push",
"loggers": ["boto3", "botocore", "s3transfer"],
"requirements": ["boto3==1.20.24"]
"requirements": ["boto3==1.28.17"]
}

View File

@ -24,7 +24,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType

View File

@ -100,7 +100,7 @@ class AmbiclimateFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
try:
token_info = await oauth.get_access_token(code)
except ambiclimate.AmbiclimateOauthError:
_LOGGER.error("Failed to get access token", exc_info=True)
_LOGGER.exception("Failed to get access token")
return None
store = Store(self.hass, STORAGE_VERSION, STORAGE_KEY)

View File

@ -5,7 +5,6 @@ from typing import Any
from aioambient import Websocket
from aioambient.errors import WebsocketError
from aioambient.util import get_public_device_id
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@ -19,11 +18,7 @@ from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription
from homeassistant.helpers.dispatcher import async_dispatcher_send
import homeassistant.helpers.entity_registry as er
from .const import (
@ -148,6 +143,7 @@ class AmbientStation:
"""Define a handler to fire when the data is received."""
mac = data["macAddress"]
# If data has not changed, don't update:
if data == self.stations[mac][ATTR_LAST_DATA]:
return
@ -196,71 +192,3 @@ class AmbientStation:
async def ws_disconnect(self) -> None:
"""Disconnect from the websocket."""
await self.websocket.disconnect()
class AmbientWeatherEntity(Entity):
"""Define a base Ambient PWS entity."""
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(
self,
ambient: AmbientStation,
mac_address: str,
station_name: str,
description: EntityDescription,
) -> None:
"""Initialize the entity."""
self._ambient = ambient
public_device_id = get_public_device_id(mac_address)
self._attr_device_info = DeviceInfo(
configuration_url=(
f"https://ambientweather.net/dashboard/{public_device_id}"
),
identifiers={(DOMAIN, mac_address)},
manufacturer="Ambient Weather",
name=station_name.capitalize(),
)
self._attr_unique_id = f"{mac_address}_{description.key}"
self._mac_address = mac_address
self.entity_description = description
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
@callback
def update() -> None:
"""Update the state."""
if self.entity_description.key == TYPE_SOLARRADIATION_LX:
self._attr_available = (
self._ambient.stations[self._mac_address][ATTR_LAST_DATA][
TYPE_SOLARRADIATION
]
is not None
)
else:
self._attr_available = (
self._ambient.stations[self._mac_address][ATTR_LAST_DATA][
self.entity_description.key
]
is not None
)
self.update_from_latest_data()
self.async_write_ha_state()
self.async_on_remove(
async_dispatcher_connect(
self.hass, f"ambient_station_data_update_{self._mac_address}", update
)
)
self.update_from_latest_data()
@callback
def update_from_latest_data(self) -> None:
"""Update the entity from the latest data."""
raise NotImplementedError

View File

@ -14,8 +14,8 @@ from homeassistant.const import ATTR_NAME, EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AmbientWeatherEntity
from .const import ATTR_LAST_DATA, DOMAIN
from .entity import AmbientWeatherEntity
TYPE_BATT1 = "batt1"
TYPE_BATT10 = "batt10"
@ -80,304 +80,303 @@ class AmbientBinarySensorDescription(
BINARY_SENSOR_DESCRIPTIONS = (
AmbientBinarySensorDescription(
key=TYPE_BATTOUT,
name="Battery",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=0,
),
AmbientBinarySensorDescription(
key=TYPE_BATT1,
name="Battery 1",
translation_key="battery_1",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=0,
),
AmbientBinarySensorDescription(
key=TYPE_BATT2,
name="Battery 2",
translation_key="battery_2",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=0,
),
AmbientBinarySensorDescription(
key=TYPE_BATT3,
name="Battery 3",
translation_key="battery_3",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=0,
),
AmbientBinarySensorDescription(
key=TYPE_BATT4,
name="Battery 4",
translation_key="battery_4",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=0,
),
AmbientBinarySensorDescription(
key=TYPE_BATT5,
name="Battery 5",
translation_key="battery_5",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=0,
),
AmbientBinarySensorDescription(
key=TYPE_BATT6,
name="Battery 6",
translation_key="battery_6",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=0,
),
AmbientBinarySensorDescription(
key=TYPE_BATT7,
name="Battery 7",
translation_key="battery_7",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=0,
),
AmbientBinarySensorDescription(
key=TYPE_BATT8,
name="Battery 8",
translation_key="battery_8",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=0,
),
AmbientBinarySensorDescription(
key=TYPE_BATT9,
name="Battery 9",
translation_key="battery_9",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=0,
),
AmbientBinarySensorDescription(
key=TYPE_BATTIN,
name="Interior battery",
translation_key="interior_battery",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=0,
),
AmbientBinarySensorDescription(
key=TYPE_BATT10,
name="Battery 10",
translation_key="battery_10",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=0,
),
AmbientBinarySensorDescription(
key=TYPE_BATT_LEAK1,
name="Leak detector battery 1",
translation_key="leak_detector_battery_1",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=1,
),
AmbientBinarySensorDescription(
key=TYPE_BATT_LEAK2,
name="Leak detector battery 2",
translation_key="leak_detector_battery_2",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=1,
),
AmbientBinarySensorDescription(
key=TYPE_BATT_LEAK3,
name="Leak detector battery 3",
translation_key="leak_detector_battery_3",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=1,
),
AmbientBinarySensorDescription(
key=TYPE_BATT_LEAK4,
name="Leak detector battery 4",
translation_key="leak_detector_battery_4",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=1,
),
AmbientBinarySensorDescription(
key=TYPE_BATT_SM1,
name="Soil monitor battery 1",
translation_key="soil_monitor_battery_1",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=0,
),
AmbientBinarySensorDescription(
key=TYPE_BATT_SM2,
name="Soil monitor battery 2",
translation_key="soil_monitor_battery_2",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=0,
),
AmbientBinarySensorDescription(
key=TYPE_BATT_SM3,
name="Soil monitor battery 3",
translation_key="soil_monitor_battery_3",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=0,
),
AmbientBinarySensorDescription(
key=TYPE_BATT_SM4,
name="Soil monitor battery 4",
translation_key="soil_monitor_battery_4",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=0,
),
AmbientBinarySensorDescription(
key=TYPE_BATT_SM5,
name="Soil monitor battery 5",
translation_key="soil_monitor_battery_5",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=0,
),
AmbientBinarySensorDescription(
key=TYPE_BATT_SM6,
name="Soil monitor battery 6",
translation_key="soil_monitor_battery_6",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=0,
),
AmbientBinarySensorDescription(
key=TYPE_BATT_SM7,
name="Soil monitor battery 7",
translation_key="soil_monitor_battery_7",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=0,
),
AmbientBinarySensorDescription(
key=TYPE_BATT_SM8,
name="Soil monitor battery 8",
translation_key="soil_monitor_battery_8",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=0,
),
AmbientBinarySensorDescription(
key=TYPE_BATT_SM9,
name="Soil monitor battery 9",
translation_key="soil_monitor_battery_9",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=0,
),
AmbientBinarySensorDescription(
key=TYPE_BATT_SM10,
name="Soil monitor battery 10",
translation_key="soil_monitor_battery_10",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=0,
),
AmbientBinarySensorDescription(
key=TYPE_BATT_CO2,
name="CO2 battery",
translation_key="co2_battery",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=0,
),
AmbientBinarySensorDescription(
key=TYPE_BATT_LIGHTNING,
name="Lightning detector battery",
translation_key="lightning_detector_battery",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=1,
),
AmbientBinarySensorDescription(
key=TYPE_LEAK1,
name="Leak detector 1",
translation_key="leak_detector_1",
device_class=BinarySensorDeviceClass.MOISTURE,
on_state=1,
),
AmbientBinarySensorDescription(
key=TYPE_LEAK2,
name="Leak detector 2",
translation_key="leak_detector_2",
device_class=BinarySensorDeviceClass.MOISTURE,
on_state=1,
),
AmbientBinarySensorDescription(
key=TYPE_LEAK3,
name="Leak detector 3",
translation_key="leak_detector_3",
device_class=BinarySensorDeviceClass.MOISTURE,
on_state=1,
),
AmbientBinarySensorDescription(
key=TYPE_LEAK4,
name="Leak detector 4",
translation_key="leak_detector_4",
device_class=BinarySensorDeviceClass.MOISTURE,
on_state=1,
),
AmbientBinarySensorDescription(
key=TYPE_PM25IN_BATT,
name="PM25 indoor battery",
translation_key="pm25_indoor_battery",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=0,
),
AmbientBinarySensorDescription(
key=TYPE_PM25_BATT,
name="PM25 battery",
translation_key="pm25_battery",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=0,
),
AmbientBinarySensorDescription(
key=TYPE_RELAY1,
name="Relay 1",
translation_key="relay_1",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=1,
),
AmbientBinarySensorDescription(
key=TYPE_RELAY2,
name="Relay 2",
translation_key="relay_2",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=1,
),
AmbientBinarySensorDescription(
key=TYPE_RELAY3,
name="Relay 3",
translation_key="relay_3",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=1,
),
AmbientBinarySensorDescription(
key=TYPE_RELAY4,
name="Relay 4",
translation_key="relay_4",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=1,
),
AmbientBinarySensorDescription(
key=TYPE_RELAY5,
name="Relay 5",
translation_key="relay_5",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=1,
),
AmbientBinarySensorDescription(
key=TYPE_RELAY6,
name="Relay 6",
translation_key="relay_6",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=1,
),
AmbientBinarySensorDescription(
key=TYPE_RELAY7,
name="Relay 7",
translation_key="relay_7",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=1,
),
AmbientBinarySensorDescription(
key=TYPE_RELAY8,
name="Relay 8",
translation_key="relay_8",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=1,
),
AmbientBinarySensorDescription(
key=TYPE_RELAY9,
name="Relay 9",
translation_key="relay_9",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=1,
),
AmbientBinarySensorDescription(
key=TYPE_RELAY10,
name="Relay 10",
translation_key="relay_10",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
on_state=1,
@ -409,9 +408,6 @@ class AmbientWeatherBinarySensor(AmbientWeatherEntity, BinarySensorEntity):
@callback
def update_from_latest_data(self) -> None:
"""Fetch new state data for the entity."""
self._attr_is_on = (
self._ambient.stations[self._mac_address][ATTR_LAST_DATA][
self.entity_description.key
]
== self.entity_description.on_state
)
description = self.entity_description
last_data = self._ambient.stations[self._mac_address][ATTR_LAST_DATA]
self._attr_is_on = last_data[description.key] == description.on_state

View File

@ -0,0 +1,70 @@
"""Base entity Ambient Weather Station Service."""
from __future__ import annotations
from aioambient.util import get_public_device_id
from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity, EntityDescription
from . import AmbientStation
from .const import ATTR_LAST_DATA, DOMAIN, TYPE_SOLARRADIATION, TYPE_SOLARRADIATION_LX
class AmbientWeatherEntity(Entity):
"""Define a base Ambient PWS entity."""
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(
self,
ambient: AmbientStation,
mac_address: str,
station_name: str,
description: EntityDescription,
) -> None:
"""Initialize the entity."""
self._ambient = ambient
public_device_id = get_public_device_id(mac_address)
self._attr_device_info = DeviceInfo(
configuration_url=(
f"https://ambientweather.net/dashboard/{public_device_id}"
),
identifiers={(DOMAIN, mac_address)},
manufacturer="Ambient Weather",
name=station_name.capitalize(),
)
self._attr_unique_id = f"{mac_address}_{description.key}"
self._mac_address = mac_address
self.entity_description = description
@callback
def _async_update(self) -> None:
"""Update the state."""
last_data = self._ambient.stations[self._mac_address][ATTR_LAST_DATA]
key = self.entity_description.key
available_key = TYPE_SOLARRADIATION if key == TYPE_SOLARRADIATION_LX else key
self._attr_available = last_data[available_key] is not None
self.update_from_latest_data()
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"ambient_station_data_update_{self._mac_address}",
self._async_update,
)
)
self.update_from_latest_data()
@callback
def update_from_latest_data(self) -> None:
"""Update the entity from the latest data."""
raise NotImplementedError

View File

@ -28,8 +28,9 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AmbientStation, AmbientWeatherEntity
from . import AmbientStation
from .const import ATTR_LAST_DATA, DOMAIN, TYPE_SOLARRADIATION, TYPE_SOLARRADIATION_LX
from .entity import AmbientWeatherEntity
TYPE_24HOURRAININ = "24hourrainin"
TYPE_AQI_PM25 = "aqi_pm25"
@ -113,544 +114,536 @@ TYPE_YEARLYRAININ = "yearlyrainin"
SENSOR_DESCRIPTIONS = (
SensorEntityDescription(
key=TYPE_24HOURRAININ,
name="24 hr rain",
translation_key="24_hour_rain",
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.TOTAL_INCREASING,
),
SensorEntityDescription(
key=TYPE_AQI_PM25,
name="AQI PM2.5",
translation_key="pm25_aqi",
device_class=SensorDeviceClass.AQI,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_AQI_PM25_24H,
name="AQI PM2.5 24h avg",
translation_key="pm25_aqi_24h_average",
device_class=SensorDeviceClass.AQI,
),
SensorEntityDescription(
key=TYPE_AQI_PM25_IN,
name="AQI PM2.5 indoor",
translation_key="pm25_indoor_aqi",
device_class=SensorDeviceClass.AQI,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_AQI_PM25_IN_24H,
name="AQI PM2.5 indoor 24h avg",
translation_key="pm25_indoor_aqi_24h_average",
device_class=SensorDeviceClass.AQI,
),
SensorEntityDescription(
key=TYPE_BAROMABSIN,
name="Abs pressure",
translation_key="absolute_pressure",
native_unit_of_measurement=UnitOfPressure.INHG,
device_class=SensorDeviceClass.PRESSURE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_BAROMRELIN,
name="Rel pressure",
translation_key="relative_pressure",
native_unit_of_measurement=UnitOfPressure.INHG,
device_class=SensorDeviceClass.PRESSURE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_CO2,
name="CO2",
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
device_class=SensorDeviceClass.CO2,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_DAILYRAININ,
name="Daily rain",
translation_key="daily_rain",
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.TOTAL_INCREASING,
),
SensorEntityDescription(
key=TYPE_DEWPOINT,
name="Dew point",
translation_key="dew_point",
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_EVENTRAININ,
name="Event rain",
translation_key="event_rain",
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.TOTAL,
),
SensorEntityDescription(
key=TYPE_FEELSLIKE,
name="Feels like",
translation_key="feels_like",
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_HOURLYRAININ,
name="Hourly rain rate",
native_unit_of_measurement=UnitOfVolumetricFlux.INCHES_PER_HOUR,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.PRECIPITATION_INTENSITY,
),
SensorEntityDescription(
key=TYPE_HUMIDITY10,
name="Humidity 10",
translation_key="humidity_10",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_HUMIDITY1,
name="Humidity 1",
translation_key="humidity_1",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_HUMIDITY2,
name="Humidity 2",
translation_key="humidity_2",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_HUMIDITY3,
name="Humidity 3",
translation_key="humidity_3",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_HUMIDITY4,
name="Humidity 4",
translation_key="humidity_4",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_HUMIDITY5,
name="Humidity 5",
translation_key="humidity_5",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_HUMIDITY6,
name="Humidity 6",
translation_key="humidity_6",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_HUMIDITY7,
name="Humidity 7",
translation_key="humidity_7",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_HUMIDITY8,
name="Humidity 8",
translation_key="humidity_8",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_HUMIDITY9,
name="Humidity 9",
translation_key="humidity_9",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_HUMIDITY,
name="Humidity",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_HUMIDITYIN,
name="Humidity in",
translation_key="humidity_indoor",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_LASTRAIN,
name="Last rain",
translation_key="last_rain",
icon="mdi:water",
device_class=SensorDeviceClass.TIMESTAMP,
),
SensorEntityDescription(
key=TYPE_LIGHTNING_PER_DAY,
name="Lightning strikes 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,
name="Lightning strikes per hour",
translation_key="lightning_strikes_per_hour",
icon="mdi:lightning-bolt",
native_unit_of_measurement="strikes",
state_class=SensorStateClass.TOTAL,
),
SensorEntityDescription(
key=TYPE_MAXDAILYGUST,
name="Max gust",
translation_key="max_gust",
native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR,
device_class=SensorDeviceClass.WIND_SPEED,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_MONTHLYRAININ,
name="Monthly rain",
translation_key="monthly_rain",
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.TOTAL,
),
SensorEntityDescription(
key=TYPE_PM25_24H,
name="PM25 24h avg",
translation_key="pm25_24h_average",
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
device_class=SensorDeviceClass.PM25,
),
SensorEntityDescription(
key=TYPE_PM25_IN,
name="PM25 indoor",
translation_key="pm25_indoor",
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
device_class=SensorDeviceClass.PM25,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_PM25_IN_24H,
name="PM25 indoor 24h avg",
translation_key="pm25_indoor_24h_average",
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
device_class=SensorDeviceClass.PM25,
),
SensorEntityDescription(
key=TYPE_PM25,
name="PM25",
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
device_class=SensorDeviceClass.PM25,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_SOILHUM10,
name="Soil humidity 10",
translation_key="soil_humidity_10",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_SOILHUM1,
name="Soil humidity 1",
translation_key="soil_humidity_1",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_SOILHUM2,
name="Soil humidity 2",
translation_key="soil_humidity_2",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_SOILHUM3,
name="Soil humidity 3",
translation_key="soil_humidity_3",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_SOILHUM4,
name="Soil humidity 4",
translation_key="soil_humidity_4",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_SOILHUM5,
name="Soil humidity 5",
translation_key="soil_humidity_5",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_SOILHUM6,
name="Soil humidity 6",
translation_key="soil_humidity_6",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_SOILHUM7,
name="Soil humidity 7",
translation_key="soil_humidity_7",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_SOILHUM8,
name="Soil humidity 8",
translation_key="soil_humidity_8",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_SOILHUM9,
name="Soil humidity 9",
translation_key="soil_humidity_9",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_SOILTEMP10F,
name="Soil temp 10",
translation_key="soil_temperature_10",
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_SOILTEMP1F,
name="Soil temp 1",
translation_key="soil_temperature_1",
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_SOILTEMP2F,
name="Soil temp 2",
translation_key="soil_temperature_2",
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_SOILTEMP3F,
name="Soil temp 3",
translation_key="soil_temperature_3",
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_SOILTEMP4F,
name="Soil temp 4",
translation_key="soil_temperature_4",
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_SOILTEMP5F,
name="Soil temp 5",
translation_key="soil_temperature_5",
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_SOILTEMP6F,
name="Soil temp 6",
translation_key="soil_temperature_6",
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_SOILTEMP7F,
name="Soil temp 7",
translation_key="soil_temperature_7",
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_SOILTEMP8F,
name="Soil temp 8",
translation_key="soil_temperature_8",
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_SOILTEMP9F,
name="Soil temp 9",
translation_key="soil_temperature_9",
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_SOLARRADIATION,
name="Solar rad",
native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER,
device_class=SensorDeviceClass.IRRADIANCE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_SOLARRADIATION_LX,
name="Solar rad",
native_unit_of_measurement=LIGHT_LUX,
device_class=SensorDeviceClass.ILLUMINANCE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_TEMP10F,
name="Temp 10",
translation_key="temperature_10",
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_TEMP1F,
name="Temp 1",
translation_key="temperature_1",
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_TEMP2F,
name="Temp 2",
translation_key="temperature_2",
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_TEMP3F,
name="Temp 3",
translation_key="temperature_3",
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_TEMP4F,
name="Temp 4",
translation_key="temperature_4",
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_TEMP5F,
name="Temp 5",
translation_key="temperature_5",
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_TEMP6F,
name="Temp 6",
translation_key="temperature_6",
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_TEMP7F,
name="Temp 7",
translation_key="temperature_7",
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_TEMP8F,
name="Temp 8",
translation_key="temperature_8",
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_TEMP9F,
name="Temp 9",
translation_key="temperature_9",
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_TEMPF,
name="Temp",
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_TEMPINF,
name="Inside temp",
translation_key="inside_temperature",
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_TOTALRAININ,
name="Lifetime rain",
translation_key="lifetime_rain",
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.TOTAL_INCREASING,
),
SensorEntityDescription(
key=TYPE_UV,
name="UV index",
translation_key="uv_index",
native_unit_of_measurement="Index",
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_WEEKLYRAININ,
name="Weekly rain",
translation_key="weekly_rain",
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.TOTAL,
),
SensorEntityDescription(
key=TYPE_WINDDIR,
name="Wind dir",
translation_key="wind_direction",
icon="mdi:weather-windy",
native_unit_of_measurement=DEGREE,
),
SensorEntityDescription(
key=TYPE_WINDDIR_AVG10M,
name="Wind dir avg 10m",
translation_key="wind_direction_average_10m",
icon="mdi:weather-windy",
native_unit_of_measurement=DEGREE,
),
SensorEntityDescription(
key=TYPE_WINDDIR_AVG2M,
name="Wind dir avg 2m",
translation_key="wind_direction_average_2m",
icon="mdi:weather-windy",
native_unit_of_measurement=DEGREE,
),
SensorEntityDescription(
key=TYPE_WINDGUSTDIR,
name="Gust dir",
translation_key="wind_gust_direction",
icon="mdi:weather-windy",
native_unit_of_measurement=DEGREE,
),
SensorEntityDescription(
key=TYPE_WINDGUSTMPH,
name="Wind gust",
translation_key="wind_gust",
native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR,
device_class=SensorDeviceClass.WIND_SPEED,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_WINDSPDMPH_AVG10M,
name="Wind avg 10m",
translation_key="wind_average_10m",
native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR,
device_class=SensorDeviceClass.WIND_SPEED,
),
SensorEntityDescription(
key=TYPE_WINDSPDMPH_AVG2M,
name="Wind avg 2m",
translation_key="wind_average_2m",
native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR,
device_class=SensorDeviceClass.WIND_SPEED,
),
SensorEntityDescription(
key=TYPE_WINDSPEEDMPH,
name="Wind speed",
native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR,
device_class=SensorDeviceClass.WIND_SPEED,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=TYPE_YEARLYRAININ,
name="Yearly rain",
translation_key="yearly_rain",
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.TOTAL_INCREASING,
@ -694,11 +687,9 @@ class AmbientWeatherSensor(AmbientWeatherEntity, SensorEntity):
@callback
def update_from_latest_data(self) -> None:
"""Fetch new state data for the sensor."""
raw = self._ambient.stations[self._mac_address][ATTR_LAST_DATA][
self.entity_description.key
]
if self.entity_description.key == TYPE_LASTRAIN:
key = self.entity_description.key
raw = self._ambient.stations[self._mac_address][ATTR_LAST_DATA][key]
if key == TYPE_LASTRAIN:
self._attr_native_value = datetime.strptime(raw, "%Y-%m-%dT%H:%M:%S.%f%z")
else:
self._attr_native_value = raw

View File

@ -16,5 +16,356 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
}
},
"entity": {
"binary_sensor": {
"battery_1": {
"name": "Battery 1"
},
"battery_2": {
"name": "Battery 2"
},
"battery_3": {
"name": "Battery 3"
},
"battery_4": {
"name": "Battery 4"
},
"battery_5": {
"name": "Battery 5"
},
"battery_6": {
"name": "Battery 6"
},
"battery_7": {
"name": "Battery 7"
},
"battery_8": {
"name": "Battery 8"
},
"battery_9": {
"name": "Battery 9"
},
"battery_10": {
"name": "Battery 10"
},
"interior_battery": {
"name": "Interior battery"
},
"leak_detector_battery_1": {
"name": "Leak detector battery 1"
},
"leak_detector_battery_2": {
"name": "Leak detector battery 2"
},
"leak_detector_battery_3": {
"name": "Leak detector battery 3"
},
"leak_detector_battery_4": {
"name": "Leak detector battery 4"
},
"soil_monitor_battery_1": {
"name": "Soil monitor battery 1"
},
"soil_monitor_battery_2": {
"name": "Soil monitor battery 2"
},
"soil_monitor_battery_3": {
"name": "Soil monitor battery 3"
},
"soil_monitor_battery_4": {
"name": "Soil monitor battery 4"
},
"soil_monitor_battery_5": {
"name": "Soil monitor battery 5"
},
"soil_monitor_battery_6": {
"name": "Soil monitor battery 6"
},
"soil_monitor_battery_7": {
"name": "Soil monitor battery 7"
},
"soil_monitor_battery_8": {
"name": "Soil monitor battery 8"
},
"soil_monitor_battery_9": {
"name": "Soil monitor battery 9"
},
"soil_monitor_battery_10": {
"name": "Soil monitor battery 10"
},
"co2_battery": {
"name": "Carbon dioxide battery"
},
"lightning_detector_battery": {
"name": "Lightning detector battery"
},
"leak_detector_1": {
"name": "Leak detector 1"
},
"leak_detector_2": {
"name": "Leak detector 2"
},
"leak_detector_3": {
"name": "Leak detector 3"
},
"leak_detector_4": {
"name": "Leak detector 4"
},
"pm25_indoor_battery": {
"name": "PM25 indoor battery"
},
"pm25_battery": {
"name": "PM25 battery"
},
"relay_1": {
"name": "Relay 1"
},
"relay_2": {
"name": "Relay 2"
},
"relay_3": {
"name": "Relay 3"
},
"relay_4": {
"name": "Relay 4"
},
"relay_5": {
"name": "Relay 5"
},
"relay_6": {
"name": "Relay 6"
},
"relay_7": {
"name": "Relay 7"
},
"relay_8": {
"name": "Relay 8"
},
"relay_9": {
"name": "Relay 9"
},
"relay_10": {
"name": "Relay 10"
}
},
"sensor": {
"24_hour_rain": {
"name": "Rain 24 hours"
},
"pm25_aqi": {
"name": "PM2.5 AQI"
},
"pm25_aqi_24h_average": {
"name": "PM2.5 AQI 24 hour average"
},
"pm25_indoor_aqi": {
"name": "PM2.5 indoor AQI"
},
"pm25_indoor_aqi_24h_average": {
"name": "PM2.5 indoor AQI"
},
"absolute_pressure": {
"name": "Absolute pressure"
},
"relative_pressure": {
"name": "Relative pressure"
},
"daily_rain": {
"name": "Daily rain"
},
"dew_point": {
"name": "Dew point"
},
"event_rain": {
"name": "Event rain"
},
"feels_like": {
"name": "Feels like"
},
"humidity_1": {
"name": "Humidity 1"
},
"humidity_2": {
"name": "Humidity 2"
},
"humidity_3": {
"name": "Humidity 3"
},
"humidity_4": {
"name": "Humidity 4"
},
"humidity_5": {
"name": "Humidity 5"
},
"humidity_6": {
"name": "Humidity 6"
},
"humidity_7": {
"name": "Humidity 7"
},
"humidity_8": {
"name": "Humidity 8"
},
"humidity_9": {
"name": "Humidity 9"
},
"humidity_10": {
"name": "Humidity 10"
},
"humidity_indoor": {
"name": "Humidity indoor"
},
"last_rain": {
"name": "Last rain"
},
"lightning_strikes_per_day": {
"name": "Lightning strikes per day"
},
"lightning_strikes_per_hour": {
"name": "Lightning strikes per hour"
},
"max_gust": {
"name": "Max gust"
},
"monthly_rain": {
"name": "Monthly rain"
},
"pm25_24h_average": {
"name": "PM2.5 24 hour average"
},
"pm25_indoor": {
"name": "PM2.5 indoor"
},
"pm25_indoor_24h_average": {
"name": "PM2.5 indoor 24 hour average"
},
"soil_humidity_1": {
"name": "Soil humidity 1"
},
"soil_humidity_2": {
"name": "Soil humidity 2"
},
"soil_humidity_3": {
"name": "Soil humidity 3"
},
"soil_humidity_4": {
"name": "Soil humidity 4"
},
"soil_humidity_5": {
"name": "Soil humidity 5"
},
"soil_humidity_6": {
"name": "Soil humidity 6"
},
"soil_humidity_7": {
"name": "Soil humidity 7"
},
"soil_humidity_8": {
"name": "Soil humidity 8"
},
"soil_humidity_9": {
"name": "Soil humidity 9"
},
"soil_humidity_10": {
"name": "Soil humidity 10"
},
"soil_temperature_1": {
"name": "Soil temperature 1"
},
"soil_temperature_2": {
"name": "Soil temperature 2"
},
"soil_temperature_3": {
"name": "Soil temperature 3"
},
"soil_temperature_4": {
"name": "Soil temperature 4"
},
"soil_temperature_5": {
"name": "Soil temperature 5"
},
"soil_temperature_6": {
"name": "Soil temperature 6"
},
"soil_temperature_7": {
"name": "Soil temperature 7"
},
"soil_temperature_8": {
"name": "Soil temperature 8"
},
"soil_temperature_9": {
"name": "Soil temperature 9"
},
"soil_temperature_10": {
"name": "Soil temperature 10"
},
"temperature_1": {
"name": "Temperature 1"
},
"temperature_2": {
"name": "Temperature 2"
},
"temperature_3": {
"name": "Temperature 3"
},
"temperature_4": {
"name": "Temperature 4"
},
"temperature_5": {
"name": "Temperature 5"
},
"temperature_6": {
"name": "Temperature 6"
},
"temperature_7": {
"name": "Temperature 7"
},
"temperature_8": {
"name": "Temperature 8"
},
"temperature_9": {
"name": "Temperature 9"
},
"temperature_10": {
"name": "Temperature 10"
},
"inside_temperature": {
"name": "Inside temperature"
},
"lifetime_rain": {
"name": "Lifetime rain"
},
"uv_index": {
"name": "UV index"
},
"weekly_rain": {
"name": "Weekly rain"
},
"wind_direction": {
"name": "Wind direction"
},
"wind_direction_average_10m": {
"name": "Wind direction average 10 minutes"
},
"wind_direction_average_2m": {
"name": "Wind direction average 2 minutes"
},
"wind_gust_direction": {
"name": "Wind gust direction"
},
"wind_gust": {
"name": "Wind gust"
},
"wind_average_10m": {
"name": "Wind average 10 minutes"
},
"wind_average_2m": {
"name": "Wind average 2 minutes"
},
"yearly_rain": {
"name": "Yearly rain"
}
}
}
}

View File

@ -2,13 +2,13 @@
from __future__ import annotations
import asyncio
from asyncio import timeout
from dataclasses import asdict as dataclass_asdict, dataclass
from datetime import datetime
from typing import Any
import uuid
import aiohttp
import async_timeout
from homeassistant.components import hassio
from homeassistant.components.api import ATTR_INSTALLATION_TYPE
@ -22,9 +22,7 @@ from homeassistant.components.recorder import (
get_instance as get_recorder_instance,
)
import homeassistant.config as conf_util
from homeassistant.config_entries import (
SOURCE_IGNORE,
)
from homeassistant.config_entries import SOURCE_IGNORE
from homeassistant.const import ATTR_DOMAIN, __version__ as HA_VERSION
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
@ -315,7 +313,7 @@ class Analytics:
)
try:
async with async_timeout.timeout(30):
async with timeout(30):
response = await self.session.post(self.endpoint, json=payload)
if response.status == 200:
LOGGER.info(

View File

@ -11,7 +11,7 @@ from homeassistant.const import (
HTTP_BASIC_AUTHENTICATION,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN

View File

@ -1,7 +1,7 @@
"""Base class for Android IP Webcam entities."""
from homeassistant.const import CONF_HOST, CONF_NAME
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN

View File

@ -32,9 +32,8 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import Throttle

View File

@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
from asyncio import timeout
import logging
from androidtvremote2 import (
@ -10,7 +11,6 @@ from androidtvremote2 import (
ConnectionClosed,
InvalidAuth,
)
import async_timeout
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP, Platform
@ -45,7 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
api.add_is_available_updated_callback(is_available_updated)
try:
async with async_timeout.timeout(5.0):
async with timeout(5.0):
await api.async_connect()
except InvalidAuth as exc:
# The Android TV is hard reset or the certificate and key files were deleted.

View File

@ -12,8 +12,12 @@ from androidtvremote2 import (
)
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components import zeroconf
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
OptionsFlowWithConfigEntry,
)
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
@ -35,7 +39,7 @@ STEP_PAIR_DATA_SCHEMA = vol.Schema(
)
class AndroidTVRemoteConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Android TV Remote."""
VERSION = 1
@ -43,7 +47,7 @@ class AndroidTVRemoteConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Initialize a new AndroidTVRemoteConfigFlow."""
self.api: AndroidTVRemote | None = None
self.reauth_entry: config_entries.ConfigEntry | None = None
self.reauth_entry: ConfigEntry | None = None
self.host: str | None = None
self.name: str | None = None
self.mac: str | None = None
@ -192,19 +196,15 @@ class AndroidTVRemoteConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> config_entries.OptionsFlow:
config_entry: ConfigEntry,
) -> AndroidTVRemoteOptionsFlowHandler:
"""Create the options flow."""
return OptionsFlowHandler(config_entry)
return AndroidTVRemoteOptionsFlowHandler(config_entry)
class OptionsFlowHandler(config_entries.OptionsFlow):
class AndroidTVRemoteOptionsFlowHandler(OptionsFlowWithConfigEntry):
"""Android TV Remote options flow."""
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
"""Initialize options flow."""
self.config_entry = config_entry
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:

View File

@ -7,8 +7,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.entity import DeviceInfo, Entity
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity import Entity
from .const import DOMAIN

View File

@ -6,7 +6,7 @@ import logging
from anova_wifi import AnovaApi, AnovaPrecisionCooker, InvalidLogin, NoDevicesFound
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.const import CONF_DEVICES, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
@ -42,7 +42,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
device[1],
api.jwt,
)
for device in entry.data["devices"]
for device in entry.data[CONF_DEVICES]
]
try:
new_devices = await api.get_devices()
@ -55,7 +55,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry,
data={
**entry.data,
**{"devices": serialize_device_list(devices)},
**{CONF_DEVICES: serialize_device_list(devices)},
},
)
coordinators = [AnovaCoordinator(hass, device) for device in devices]

View File

@ -4,16 +4,16 @@ from __future__ import annotations
from anova_wifi import AnovaApi, InvalidLogin, NoDevicesFound
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.config_entries import ConfigFlow
from homeassistant.const import CONF_DEVICES, CONF_PASSWORD, CONF_USERNAME
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
from .util import serialize_device_list
class AnovaConfligFlow(config_entries.ConfigFlow, domain=DOMAIN):
class AnovaConfligFlow(ConfigFlow, domain=DOMAIN):
"""Sets up a config flow for Anova."""
VERSION = 1
@ -25,7 +25,7 @@ class AnovaConfligFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {}
if user_input is not None:
api = AnovaApi(
aiohttp_client.async_get_clientsession(self.hass),
async_get_clientsession(self.hass),
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
)
@ -48,7 +48,7 @@ class AnovaConfligFlow(config_entries.ConfigFlow, domain=DOMAIN):
data={
CONF_USERNAME: api.username,
CONF_PASSWORD: api.password,
"devices": device_list,
CONF_DEVICES: device_list,
},
)

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